diff --git a/assets/css/app.css b/assets/css/app.css index 5f967208..20715f61 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,89 +1,119 @@ -@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap'); - @import "tailwindcss" source(none); +@import "tw-animate-css"; @source "../css"; @source "../js"; @source "../vue"; @source "../../lib/jido_code_web"; -@source "../../deps/live_toast/lib"; @import "./syntax.css"; @plugin "../vendor/heroicons"; -@plugin "daisyui" { - themes: false; +:root, +[data-theme="light"] { + color-scheme: light; + --background: oklch(0.99 0.002 247); + --foreground: oklch(0.19 0.014 260); + --card: oklch(1 0 0); + --card-foreground: oklch(0.19 0.014 260); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.19 0.014 260); + --primary: oklch(0.45 0.12 254); + --primary-foreground: oklch(0.99 0.002 247); + --secondary: oklch(0.95 0.006 260); + --secondary-foreground: oklch(0.25 0.016 260); + --muted: oklch(0.96 0.006 260); + --muted-foreground: oklch(0.49 0.02 260); + --accent: oklch(0.93 0.028 186); + --accent-foreground: oklch(0.24 0.034 190); + --destructive: oklch(0.57 0.2 28); + --destructive-foreground: oklch(0.99 0.002 247); + --shadcn-border: oklch(0.9 0.01 260); + --input: oklch(0.9 0.01 260); + --ring: oklch(0.56 0.12 254); + --radius: 0.5rem; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color-scheme: dark; + --background: oklch(0.16 0.014 260); + --foreground: oklch(0.96 0.004 247); + --card: oklch(0.2 0.016 260); + --card-foreground: oklch(0.96 0.004 247); + --popover: oklch(0.2 0.016 260); + --popover-foreground: oklch(0.96 0.004 247); + --primary: oklch(0.72 0.11 254); + --primary-foreground: oklch(0.16 0.014 260); + --secondary: oklch(0.27 0.018 260); + --secondary-foreground: oklch(0.96 0.004 247); + --muted: oklch(0.26 0.016 260); + --muted-foreground: oklch(0.72 0.016 260); + --accent: oklch(0.31 0.052 186); + --accent-foreground: oklch(0.92 0.035 184); + --destructive: oklch(0.64 0.19 28); + --destructive-foreground: oklch(0.99 0.002 247); + --shadcn-border: oklch(0.33 0.018 260); + --input: oklch(0.33 0.018 260); + --ring: oklch(0.72 0.11 254); + } } -@plugin "daisyui/theme" { - name: "dark"; - default: true; - prefersdark: true; - color-scheme: "dark"; - --color-base-100: hsl(0 0% 4%); - --color-base-200: hsl(0 0% 7%); - --color-base-300: hsl(0 0% 10%); - --color-base-content: hsl(0 0% 91%); - --color-primary: hsl(152 100% 50%); - --color-primary-content: hsl(0 0% 4%); - --color-secondary: hsl(0 0% 10%); - --color-secondary-content: hsl(0 0% 85%); - --color-accent: hsl(192 100% 50%); - --color-accent-content: hsl(0 0% 4%); - --color-neutral: hsl(0 0% 16%); - --color-neutral-content: hsl(0 0% 91%); - --color-info: hsl(192 100% 50%); - --color-info-content: hsl(0 0% 4%); - --color-success: hsl(152 100% 50%); - --color-success-content: hsl(0 0% 4%); - --color-warning: hsl(43 100% 50%); - --color-warning-content: hsl(0 0% 4%); - --color-error: hsl(0 73% 71%); - --color-error-content: hsl(0 0% 4%); - --radius-selector: 0.375rem; - --radius-field: 0.375rem; - --radius-box: 0.5rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1.5px; - --depth: 1; - --noise: 0; +.dark, +[data-theme="dark"] { + color-scheme: dark; + --background: oklch(0.16 0.014 260); + --foreground: oklch(0.96 0.004 247); + --card: oklch(0.2 0.016 260); + --card-foreground: oklch(0.96 0.004 247); + --popover: oklch(0.2 0.016 260); + --popover-foreground: oklch(0.96 0.004 247); + --primary: oklch(0.72 0.11 254); + --primary-foreground: oklch(0.16 0.014 260); + --secondary: oklch(0.27 0.018 260); + --secondary-foreground: oklch(0.96 0.004 247); + --muted: oklch(0.26 0.016 260); + --muted-foreground: oklch(0.72 0.016 260); + --accent: oklch(0.31 0.052 186); + --accent-foreground: oklch(0.92 0.035 184); + --destructive: oklch(0.64 0.19 28); + --destructive-foreground: oklch(0.99 0.002 247); + --shadcn-border: oklch(0.33 0.018 260); + --input: oklch(0.33 0.018 260); + --ring: oklch(0.72 0.11 254); } -@plugin "daisyui/theme" { - name: "light"; - default: false; - prefersdark: false; - color-scheme: "light"; - --color-base-100: hsl(40 33% 98%); - --color-base-200: hsl(40 20% 96%); - --color-base-300: hsl(40 20% 92%); - --color-base-content: hsl(24 10% 10%); - --color-primary: hsl(160 84% 39%); - --color-primary-content: hsl(0 0% 98%); - --color-secondary: hsl(40 20% 94%); - --color-secondary-content: hsl(24 10% 10%); - --color-accent: hsl(189 94% 43%); - --color-accent-content: hsl(0 0% 100%); - --color-neutral: hsl(24 8% 45%); - --color-neutral-content: hsl(0 0% 100%); - --color-info: hsl(189 94% 43%); - --color-info-content: hsl(0 0% 100%); - --color-success: hsl(160 84% 39%); - --color-success-content: hsl(0 0% 100%); - --color-warning: hsl(32 95% 44%); - --color-warning-content: hsl(0 0% 100%); - --color-error: hsl(0 72% 51%); - --color-error-content: hsl(0 0% 100%); - --radius-selector: 0.375rem; - --radius-field: 0.375rem; - --radius-box: 0.5rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1.5px; - --depth: 1; - --noise: 0; +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--shadcn-border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-xs: calc(var(--radius) - 6px); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +body { + background: var(--background); + color: var(--foreground); } @custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); @@ -95,494 +125,3 @@ [data-phx-teleported-src] { display: contents; } - -@utility phx-click-loading { - opacity: 0.5; - transition: opacity 1s ease-out; -} - -@utility phx-loading { - cursor: wait; -} - -@theme inline { - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - - --color-border: hsl(var(--border)); - --color-border-strong: hsl(var(--border-strong)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-surface: hsl(var(--surface)); - --color-elevated: hsl(var(--elevated)); - - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - --color-accent-green: hsl(var(--accent-green)); - --color-accent-yellow: hsl(var(--accent-yellow)); - --color-accent-cyan: hsl(var(--accent-cyan)); - --color-accent-red: hsl(var(--accent-red)); - - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - - --color-sidebar: hsl(var(--sidebar-background)); - --color-sidebar-foreground: hsl(var(--sidebar-foreground)); - --color-sidebar-primary: hsl(var(--sidebar-primary)); - --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); - --color-sidebar-accent: hsl(var(--sidebar-accent)); - --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); - --color-sidebar-border: hsl(var(--sidebar-border)); - --color-sidebar-ring: hsl(var(--sidebar-ring)); - - --color-code-bg: hsl(var(--code-bg)); - --color-code-border: hsl(var(--code-border)); - --color-code-keyword: hsl(var(--code-keyword)); - --color-code-string: hsl(var(--code-string)); - --color-code-comment: hsl(var(--code-comment)); - --color-code-function: hsl(var(--code-function)); - --color-code-type: hsl(var(--code-type)); - - --font-mono: "IBM Plex Mono", ui-monospace, monospace; - - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - --animate-fade-in: fade-in 0.5s ease-out forwards; - - --container-2xl: 1000px; - - @keyframes fade-in { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } - } -} - -@layer base { - :root, - :root[data-theme="dark"], - :root.dark { - --background: 0 0% 4%; - --foreground: 0 0% 91%; - --surface: 0 0% 7%; - --elevated: 0 0% 10%; - --card: 0 0% 7%; - --card-foreground: 0 0% 91%; - --popover: 0 0% 7%; - --popover-foreground: 0 0% 91%; - --primary: 152 100% 50%; - --primary-foreground: 0 0% 4%; - --secondary: 0 0% 10%; - --secondary-foreground: 0 0% 85%; - --muted: 0 0% 16%; - --muted-foreground: 0 0% 40%; - --accent-green: 152 100% 50%; - --accent-yellow: 43 100% 50%; - --accent-cyan: 192 100% 50%; - --accent-red: 0 73% 71%; - --accent: 192 100% 50%; - --accent-foreground: 0 0% 4%; - --destructive: 0 73% 71%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 16%; - --border-strong: 0 0% 23%; - --input: 0 0% 16%; - --ring: 152 100% 50%; - --radius: 0.375rem; - --code-bg: 0 0% 3%; - --code-border: 0 0% 16%; - --code-keyword: 152 100% 50%; - --code-string: 43 100% 50%; - --code-comment: 0 0% 40%; - --code-function: 192 100% 50%; - --code-type: 0 73% 71%; - --sidebar-background: 0 0% 5%; - --sidebar-foreground: 0 0% 90%; - --sidebar-primary: 152 100% 50%; - --sidebar-primary-foreground: 0 0% 4%; - --sidebar-accent: 0 0% 10%; - --sidebar-accent-foreground: 0 0% 90%; - --sidebar-border: 0 0% 16%; - --sidebar-ring: 152 100% 50%; - } - - :root[data-theme="light"], - :root.light { - --background: 40 33% 98%; - --foreground: 24 10% 10%; - --surface: 0 0% 100%; - --elevated: 40 20% 96%; - --card: 0 0% 100%; - --card-foreground: 24 10% 10%; - --popover: 0 0% 100%; - --popover-foreground: 24 10% 10%; - --primary: 160 84% 39%; - --primary-foreground: 0 0% 98%; - --secondary: 40 20% 94%; - --secondary-foreground: 24 10% 10%; - --muted: 40 20% 92%; - --muted-foreground: 24 8% 45%; - --accent-green: 160 84% 39%; - --accent-yellow: 32 95% 44%; - --accent-cyan: 189 94% 43%; - --accent-red: 0 72% 51%; - --accent: 189 94% 43%; - --accent-foreground: 0 0% 100%; - --destructive: 0 72% 51%; - --destructive-foreground: 0 0% 98%; - --border: 24 6% 91%; - --border-strong: 24 6% 84%; - --input: 24 6% 91%; - --ring: 160 84% 39%; - --code-bg: 40 20% 96%; - --code-border: 24 6% 91%; - --code-keyword: 160 84% 39%; - --code-string: 32 95% 44%; - --code-comment: 24 8% 65%; - --code-function: 189 94% 43%; - --code-type: 0 72% 51%; - --sidebar-background: 40 33% 98%; - --sidebar-foreground: 24 10% 10%; - --sidebar-primary: 160 84% 39%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 40 20% 92%; - --sidebar-accent-foreground: 24 10% 10%; - --sidebar-border: 24 6% 91%; - --sidebar-ring: 160 84% 39%; - } - - :root:not([data-theme]) { - --background: 0 0% 4%; - --foreground: 0 0% 91%; - --surface: 0 0% 7%; - --elevated: 0 0% 10%; - --card: 0 0% 7%; - --card-foreground: 0 0% 91%; - --popover: 0 0% 7%; - --popover-foreground: 0 0% 91%; - --primary: 152 100% 50%; - --primary-foreground: 0 0% 4%; - --secondary: 0 0% 10%; - --secondary-foreground: 0 0% 85%; - --muted: 0 0% 16%; - --muted-foreground: 0 0% 40%; - --accent-green: 152 100% 50%; - --accent-yellow: 43 100% 50%; - --accent-cyan: 192 100% 50%; - --accent-red: 0 73% 71%; - --accent: 192 100% 50%; - --accent-foreground: 0 0% 4%; - --destructive: 0 73% 71%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 16%; - --border-strong: 0 0% 23%; - --input: 0 0% 16%; - --ring: 152 100% 50%; - --radius: 0.375rem; - --code-bg: 0 0% 3%; - --code-border: 0 0% 16%; - --code-keyword: 152 100% 50%; - --code-string: 43 100% 50%; - --code-comment: 0 0% 40%; - --code-function: 192 100% 50%; - --code-type: 0 73% 71%; - } - - @media (prefers-color-scheme: light) { - :root:not([data-theme]) { - --background: 40 33% 98%; - --foreground: 24 10% 10%; - --surface: 0 0% 100%; - --elevated: 40 20% 96%; - --card: 0 0% 100%; - --card-foreground: 24 10% 10%; - --popover: 0 0% 100%; - --popover-foreground: 24 10% 10%; - --primary: 160 84% 39%; - --primary-foreground: 0 0% 98%; - --secondary: 40 20% 94%; - --secondary-foreground: 24 10% 10%; - --muted: 40 20% 92%; - --muted-foreground: 24 8% 45%; - --accent-green: 160 84% 39%; - --accent-yellow: 32 95% 44%; - --accent-cyan: 189 94% 43%; - --accent-red: 0 72% 51%; - --accent: 189 94% 43%; - --accent-foreground: 0 0% 100%; - --destructive: 0 72% 51%; - --destructive-foreground: 0 0% 98%; - --border: 24 6% 91%; - --border-strong: 24 6% 84%; - --input: 24 6% 91%; - --ring: 160 84% 39%; - --code-bg: 40 20% 96%; - --code-border: 24 6% 91%; - --code-keyword: 160 84% 39%; - --code-string: 32 95% 44%; - --code-comment: 24 8% 65%; - --code-function: 189 94% 43%; - --code-type: 0 72% 51%; - } - } - - .prose { - --tw-prose-body: hsl(var(--foreground)); - --tw-prose-headings: hsl(var(--foreground)); - --tw-prose-lead: hsl(var(--muted-foreground)); - --tw-prose-links: hsl(var(--primary)); - --tw-prose-bold: hsl(var(--foreground)); - --tw-prose-counters: hsl(var(--muted-foreground)); - --tw-prose-bullets: hsl(var(--muted-foreground)); - --tw-prose-hr: hsl(var(--border)); - --tw-prose-quotes: hsl(var(--foreground)); - --tw-prose-quote-borders: hsl(var(--border)); - --tw-prose-captions: hsl(var(--muted-foreground)); - --tw-prose-code: hsl(var(--foreground)); - --tw-prose-pre-code: hsl(var(--foreground)); - --tw-prose-pre-bg: hsl(var(--code-bg)); - --tw-prose-th-borders: hsl(var(--border)); - --tw-prose-td-borders: hsl(var(--border)); - } - - body { - @apply bg-background text-foreground antialiased; - font-family: var(--font-mono); - } - - html { - scroll-behavior: smooth; - } - - ::selection { - background: hsl(var(--primary) / 0.3); - } - - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} - -@layer components { - .package-card { - @apply bg-card border border-border rounded-md p-5 transition-all duration-200; - } - - .package-card:hover { - @apply border-border-strong; - } - - .package-card-core { - @apply bg-card border border-border rounded-md p-5 transition-all duration-200 border-t-[3px]; - border-top-color: hsl(var(--accent-green)); - } - - .package-card-ai { - @apply bg-card border border-border rounded-md p-5 transition-all duration-200 border-t-[3px]; - border-top-color: hsl(var(--accent-yellow)); - } - - .package-card-foundation { - @apply bg-card border border-border rounded-md p-5 transition-all duration-200 border-t-[3px]; - border-top-color: hsl(var(--accent-cyan)); - } - - .package-card-app { - @apply bg-card border border-border rounded-md p-5 transition-all duration-200 border-t-[3px]; - border-top-color: hsl(var(--accent-red)); - } - - .badge-core { - @apply text-[9px] font-bold tracking-wider px-2 py-0.5 rounded; - color: hsl(var(--accent-green)); - background: hsl(var(--accent-green) / 0.15); - } - - .badge-ai { - @apply text-[9px] font-bold tracking-wider px-2 py-0.5 rounded; - color: hsl(var(--accent-yellow)); - background: hsl(var(--accent-yellow) / 0.15); - } - - .badge-foundation { - @apply text-[9px] font-bold tracking-wider px-2 py-0.5 rounded; - color: hsl(var(--accent-cyan)); - background: hsl(var(--accent-cyan) / 0.15); - } - - .badge-app { - @apply text-[9px] font-bold tracking-wider px-2 py-0.5 rounded; - color: hsl(var(--accent-red)); - background: hsl(var(--accent-red) / 0.15); - } - - .code-block { - @apply rounded-md border overflow-x-auto; - background: hsl(var(--code-bg)); - border-color: hsl(var(--code-border)); - } - - .code-header { - @apply px-5 py-3 border-b flex items-center justify-between; - background: hsl(var(--elevated)); - border-color: hsl(var(--code-border)); - } - - .metric-card { - @apply text-center py-5 px-4; - background: hsl(var(--surface)); - } - - .feature-card { - @apply bg-card border border-border rounded-md p-6 transition-all duration-200; - } - - .feature-card:hover { - border-color: hsl(var(--primary) / 0.5); - } - - .gradient-text { - background: linear-gradient(135deg, hsl(var(--accent-green)), hsl(var(--accent-yellow))); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .cta-glow { - background: hsl(var(--primary) / 0.05); - border: 1px solid hsl(var(--primary) / 0.2); - } - - .nav-surface { - @apply bg-card border border-border rounded-md; - } - - .icon-card { - @apply bg-card border border-border rounded-md p-5 transition-colors; - } - - .icon-card:hover { - border-color: hsl(var(--primary) / 0.4); - } - - .numbered-card { - @apply bg-card border border-border rounded-md p-5; - } - - .quickstart-card { - @apply bg-card border border-border rounded-md p-4 flex items-center gap-3 transition-colors; - } - - .quickstart-card:hover { - border-color: hsl(var(--primary) / 0.4); - } -} - -@layer utilities { - .syntax-keyword { - color: hsl(var(--code-keyword)); - } - - .syntax-string { - color: hsl(var(--code-string)); - } - - .syntax-comment { - color: hsl(var(--code-comment)); - } - - .syntax-function { - color: hsl(var(--code-function)); - } - - .syntax-type { - color: hsl(var(--code-type)); - } - - .syntax-module { - color: hsl(var(--code-type)); - } - - .syntax-atom { - color: hsl(var(--code-function)); - } - - .syntax-number { - color: hsl(var(--code-string)); - } - - .syntax-operator { - color: hsl(var(--muted-foreground)); - } - - .text-accent-green { - color: hsl(var(--accent-green)); - } - - .text-accent-yellow { - color: hsl(var(--accent-yellow)); - } - - .text-accent-cyan { - color: hsl(var(--accent-cyan)); - } - - .text-accent-red { - color: hsl(var(--accent-red)); - } - - .bg-accent-green { - background-color: hsl(var(--accent-green)); - } - - .bg-accent-yellow { - background-color: hsl(var(--accent-yellow)); - } - - .bg-accent-cyan { - background-color: hsl(var(--accent-cyan)); - } - - .bg-accent-red { - background-color: hsl(var(--accent-red)); - } - - .border-accent-green { - border-color: hsl(var(--accent-green)); - } - - .border-accent-yellow { - border-color: hsl(var(--accent-yellow)); - } - - .border-accent-cyan { - border-color: hsl(var(--accent-cyan)); - } - - .border-accent-red { - border-color: hsl(var(--accent-red)); - } -} diff --git a/assets/js/app.js b/assets/js/app.js index ab58f632..7b0c1b1e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,102 +1,48 @@ -import "vite/modulepreload-polyfill"; -// If you want to use Phoenix channels, run `mix help phx.gen.channel` -// to get started and then uncomment the line below. -// import "./user_socket.js" -// You can include dependencies in two ways. -// -// The simplest option is to put them in assets/vendor and -// import them using relative paths: -// -// import "../vendor/some-package.js" -// -// Alternatively, you can `npm install some-package --prefix assets` and import -// them using a path starting with the package name: -// -// import "some-package" -// -// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. -// To load it, simply add a second `` to your `root.html.heex` file. -// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import "phoenix_html"; -// Establish Phoenix Socket and LiveView configuration. -import { Socket } from "phoenix"; -import { LiveSocket } from "phoenix_live_view"; -import { getHooks } from "live_vue"; -import liveVueApp from "../vue"; -import topbar from "topbar"; -import { createLiveToastHook } from "../../deps/live_toast/priv/static/live_toast.esm.js"; - -const csrfToken = document - .querySelector("meta[name='csrf-token']") - .getAttribute("content"); +import "vite/modulepreload-polyfill" +import "phoenix_html" +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import {getHooks} from "live_vue" +import liveVueApp from "../vue" +import {hooks as colocatedHooks} from "phoenix-colocated/jido_code" +import SaladUI from "salad_ui" +import "salad_ui/components/dialog" +import "salad_ui/components/tabs" +import topbar from "topbar" +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: { - _csrf_token: csrfToken, - }, - hooks: { - LiveToast: createLiveToastHook(), - ...getHooks(liveVueApp), - }, -}); + params: {_csrf_token: csrfToken}, + hooks: {...colocatedHooks, ...getHooks(liveVueApp), SaladUI: SaladUI.SaladUIHook}, +}) -// Show progress bar on live navigation and form submits -topbar.config({ - barColors: { - 0: "#29d", - }, - shadowColor: "rgba(0, 0, 0, .3)", -}); +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) -window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); -window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); +liveSocket.connect() +window.liveSocket = liveSocket -// connect if there are any LiveViews on the page -liveSocket.connect(); +if (process.env.NODE_ENV === "development") { + window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + reloader.enableServerLogs() -// expose liveSocket on window for web console debug logs and latency simulation: -// >> liveSocket.enableDebug() -// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session -// >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket; + let keyDown + window.addEventListener("keydown", e => keyDown = e.key) + window.addEventListener("keyup", () => keyDown = null) + window.addEventListener("click", e => { + if (keyDown === "c") { + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtCaller(e.target) + } else if (keyDown === "d") { + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtDef(e.target) + } + }, true) -// The lines below enable quality of life phoenix_live_reload -// development features: -// -// 1. stream server logs to the browser console -// 2. click on elements to jump to their definitions in your code editor -// -if (process.env.NODE_ENV === "development") { - window.addEventListener( - "phx:live_reload:attached", - ({ detail: reloader }) => { - // Enable server log streaming to client. - // Disable with reloader.disableServerLogs() - reloader.enableServerLogs(); - // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component - // - // * click with "c" key pressed to open at caller location - // * click with "d" key pressed to open at function component definition location - let keyDown; - window.addEventListener("keydown", (e) => (keyDown = e.key)); - window.addEventListener("keyup", (_e) => (keyDown = null)); - window.addEventListener( - "click", - (e) => { - if (keyDown === "c") { - e.preventDefault(); - e.stopImmediatePropagation(); - reloader.openEditorAtCaller(e.target); - } else if (keyDown === "d") { - e.preventDefault(); - e.stopImmediatePropagation(); - reloader.openEditorAtDef(e.target); - } - }, - true, - ); - window.liveReloader = reloader; - }, - ); + window.liveReloader = reloader + }) } diff --git a/assets/tsconfig.json b/assets/tsconfig.json new file mode 100644 index 00000000..e1dd41d9 --- /dev/null +++ b/assets/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "isolatedModules": true, + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "paths": { + "@/*": ["./*"] + }, + "resolveJsonModule": true, + "target": "ES2022", + "types": ["vite/client"] + }, + "include": ["js/**/*", "vue/**/*", "vue/**/*.vue", "vue/env.d.ts"] +} diff --git a/assets/vendor/carousel.js b/assets/vendor/carousel.js deleted file mode 100644 index 722f20c7..00000000 --- a/assets/vendor/carousel.js +++ /dev/null @@ -1,600 +0,0 @@ -const Carousel = { - mounted() { - try { - this.initializeElements(); - this.setupState(); - this.setupEventListeners(); - this.setupImageLoadHandling(); - this.setupKeyboardNavigation(); - this.setupTouchNavigation(); - this.setupResizeHandling(); - this.showInitialSlide(); - this.setupAutoplay(); - this.setupIntersectionObserver(); - - this.handleEvent("select-slide", ({ index }) => this.selectSlide(index)); - } catch (error) { - console.error(`Carousel initialization error: ${error.message}`); - } - }, - - disconnected() { - this.cleanupEventListeners(); - this.stopAutoplay(); - if (this.intersectionObserver) { - this.intersectionObserver.disconnect(); - } - }, - - initializeElements() { - this.slides = this.el.querySelectorAll(".slide"); - if (!this.slides.length) { - throw new Error("No slides found in carousel"); - } - - this.indicators = this.el.querySelectorAll(".carousel-indicator"); - this.prevButtons = [ - this.el.querySelector(`#${this.el.id}-carousel-prev`), - ].filter(Boolean); - this.nextButtons = [ - this.el.querySelector(`#${this.el.id}-carousel-next`), - ].filter(Boolean); - this.imageLoaders = this.el.querySelectorAll( - `[id^="${this.el.id}-carousel-slide-image-"]`, - ); - - this.prevButtons.forEach((button) => { - if (!button.getAttribute("aria-label")) { - button.setAttribute("aria-label", "Previous slide"); - } - }); - - this.nextButtons.forEach((button) => { - if (!button.getAttribute("aria-label")) { - button.setAttribute("aria-label", "Next slide"); - } - }); - - if (!this.el.getAttribute("role")) { - this.el.setAttribute("role", "region"); - this.el.setAttribute("aria-roledescription", "carousel"); - } - - // Set sanitized ARIA attributes for each slide - this.slides.forEach((slide, index) => { - slide.setAttribute("role", "group"); - slide.setAttribute("aria-roledescription", "slide"); - slide.setAttribute( - "aria-label", - `Slide ${this.sanitizeNumber(index) + 1} of ${this.sanitizeNumber(this.slides.length)}`, - ); - }); - }, - - setupState() { - // Validate active index strictly - const rawIndex = this.el.dataset.activeIndex; - this.activeIndex = /^\d+$/.test(rawIndex) - ? Math.max(1, Math.min(parseInt(rawIndex, 10), this.slides.length)) - : 1; - - this.totalSlides = this.slides.length; - - // Validate class names to prevent injection - this.classes = { - activeSlide: - this.sanitizeClassName(this.el.dataset.activeSlideClass) || - "active-slide z-10", - hiddenSlide: - this.sanitizeClassName(this.el.dataset.hiddenSlideClass) || "opacity-0", - activeIndicator: - this.sanitizeClassName(this.el.dataset.activeIndicatorClass) || - "active-indicator", - }; - - // Validate boolean values - this.shouldAutoplay = this.el.dataset.autoplay === "true"; - - // Validate numeric values and add a maximum for safety - const rawInterval = parseInt(this.el.dataset.autoplayInterval, 10); - this.autoplayInterval = - !isNaN(rawInterval) && rawInterval > 0 - ? Math.min(rawInterval, 30000) - : 5000; - - this.autoplayTimer = null; - this.userInteractionDelay = 7000; - this.userInteractionTimeout = null; - this.isHovered = false; - this.isTouching = false; - this.touchStartX = 0; - this.touchEndX = 0; - this.minSwipeDistance = 50; - }, - - setupEventListeners() { - this.eventHandlers = { - prevClick: (e) => { - this.handleUserInteraction(); - this.handlePrevClick(e); - }, - nextClick: (e) => { - this.handleUserInteraction(); - this.handleNextClick(e); - }, - indicatorClick: (index) => { - this.handleUserInteraction(); - this.handleIndicatorClick(index + 1); - }, - mouseEnter: () => { - this.isHovered = true; - if (this.shouldAutoplay && this.el.dataset.pauseOnHover !== "false") { - this.stopAutoplay(); - } - }, - mouseLeave: () => { - this.isHovered = false; - if ( - this.shouldAutoplay && - !this.isTouching && - this.el.dataset.pauseOnHover !== "false" - ) { - this.startAutoplay(); - } - }, - touchStart: (e) => { - this.isTouching = true; - this.touchStartX = e.touches[0].clientX; - if (this.shouldAutoplay) { - this.stopAutoplay(); - } - }, - touchMove: (e) => { - if (!this.isTouching) return; - this.touchEndX = e.touches[0].clientX; - }, - touchEnd: () => { - this.isTouching = false; - this.handleSwipe(); - if (this.shouldAutoplay && !this.isHovered) { - this.startAutoplay(); - } - }, - keyDown: (e) => this.handleKeyDown(e), - resize: () => this.handleResize(), - }; - - this.prevButtons.forEach((button) => { - button?.addEventListener("click", this.eventHandlers.prevClick); - }); - - this.nextButtons.forEach((button) => { - button?.addEventListener("click", this.eventHandlers.nextClick); - }); - - this.indicators?.forEach((indicator, index) => { - indicator.addEventListener("click", () => - this.eventHandlers.indicatorClick(index), - ); - }); - - if (this.el.dataset.pauseOnHover !== "false") { - this.el.addEventListener("mouseenter", this.eventHandlers.mouseEnter); - this.el.addEventListener("mouseleave", this.eventHandlers.mouseLeave); - } - }, - - cleanupEventListeners() { - this.prevButtons.forEach((button) => { - button?.removeEventListener("click", this.eventHandlers.prevClick); - }); - - this.nextButtons.forEach((button) => { - button?.removeEventListener("click", this.eventHandlers.nextClick); - }); - - this.indicators?.forEach((indicator, index) => { - indicator.removeEventListener("click", () => - this.eventHandlers.indicatorClick(index), - ); - }); - - this.el.removeEventListener("mouseenter", this.eventHandlers.mouseEnter); - this.el.removeEventListener("mouseleave", this.eventHandlers.mouseLeave); - - document.removeEventListener("keydown", this.eventHandlers.keyDown); - window.removeEventListener("resize", this.eventHandlers.resize); - - this.el.removeEventListener("touchstart", this.eventHandlers.touchStart); - this.el.removeEventListener("touchmove", this.eventHandlers.touchMove); - this.el.removeEventListener("touchend", this.eventHandlers.touchEnd); - }, - - setupKeyboardNavigation() { - document.addEventListener("keydown", this.eventHandlers.keyDown); - }, - - handleKeyDown(e) { - const hasFocus = - this.el.contains(document.activeElement) || - document.activeElement === this.el; - - if (!hasFocus) return; - - if (e.key === "ArrowLeft" || e.key === "ArrowUp") { - this.handleUserInteraction(); - this.handlePrevClick(e); - e.preventDefault(); - } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { - this.handleUserInteraction(); - this.handleNextClick(e); - e.preventDefault(); - } - }, - - setupTouchNavigation() { - this.el.addEventListener("touchstart", this.eventHandlers.touchStart, { - passive: true, - }); - this.el.addEventListener("touchmove", this.eventHandlers.touchMove, { - passive: true, - }); - this.el.addEventListener("touchend", this.eventHandlers.touchEnd); - }, - - handleSwipe() { - if (!this.touchStartX || !this.touchEndX) return; - - const swipeDistance = this.touchEndX - this.touchStartX; - - if (Math.abs(swipeDistance) > this.minSwipeDistance) { - if (swipeDistance > 0) { - this.handlePrevClick(new Event("swipe")); - } else { - this.handleNextClick(new Event("swipe")); - } - } - - this.touchStartX = 0; - this.touchEndX = 0; - }, - - setupResizeHandling() { - window.addEventListener("resize", this.eventHandlers.resize); - }, - - handleResize() { - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - } - - this.resizeTimeout = setTimeout(() => { - this.updateSlideSizes(); - }, 100); - }, - - updateSlideSizes() { - const containerWidth = this.el.clientWidth; - this.slides.forEach((slide) => { - const img = slide.querySelector("img"); - if (img) { - const aspectRatio = img.naturalHeight / img.naturalWidth; - img.style.maxHeight = `${containerWidth * aspectRatio}px`; - } - }); - }, - - setupIntersectionObserver() { - if ("IntersectionObserver" in window && this.shouldAutoplay) { - this.intersectionObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - if (this.shouldAutoplay && !this.isHovered && !this.isTouching) { - this.startAutoplay(); - } - } else { - this.stopAutoplay(); - } - }); - }, - { threshold: 0.2 }, - ); - this.intersectionObserver.observe(this.el); - } - }, - - handlePrevClick(e) { - const targetIndex = - this.activeIndex > 1 ? this.activeIndex - 1 : this.totalSlides; - this.navigateToSlide(targetIndex); - e.stopPropagation(); - }, - - handleNextClick(e) { - const targetIndex = - this.activeIndex < this.totalSlides ? this.activeIndex + 1 : 1; - this.navigateToSlide(targetIndex); - e.stopPropagation(); - }, - - handleIndicatorClick(targetIndex) { - if (targetIndex === this.activeIndex) return; - this.navigateToSlide(targetIndex); - }, - - navigateToSlide(targetIndex) { - // Validate targetIndex strictly - if ( - !/^\d+$/.test(String(targetIndex)) || - targetIndex < 1 || - targetIndex > this.totalSlides - ) { - console.warn(`Invalid slide index: ${targetIndex}`); - return; - } - - const currentSlide = this.slides[this.activeIndex - 1]; - const targetSlide = this.slides[targetIndex - 1]; - - if (!currentSlide || !targetSlide) { - console.warn("Cannot navigate: missing slide elements"); - return; - } - - const targetImage = targetSlide.querySelector("img"); - - this.announceSlideChange(targetIndex); - - this.performSlideTransition(currentSlide, targetSlide, targetIndex); - - if ( - targetImage && - !targetImage.complete && - targetImage.naturalWidth === 0 - ) { - this.showImageLoadingOverlay(targetImage, targetSlide); - } - - const event = new CustomEvent("carousel:slide-changed", { - bubbles: true, - detail: { - previousIndex: parseInt(this.activeIndex, 10), - currentIndex: parseInt(targetIndex, 10), - totalSlides: parseInt(this.totalSlides, 10), - }, - }); - this.el.dispatchEvent(event); - }, - - announceSlideChange(targetIndex) { - let liveRegion = document.getElementById(`${this.el.id}-live-region`); - - if (!liveRegion) { - liveRegion = document.createElement("div"); - liveRegion.id = `${this.el.id}-live-region`; - liveRegion.className = "sr-only"; - liveRegion.setAttribute("aria-live", "polite"); - liveRegion.setAttribute("aria-atomic", "true"); - this.el.appendChild(liveRegion); - } - - liveRegion.textContent = `Slide ${targetIndex} of ${this.totalSlides}`; - }, - - performSlideTransition(currentSlide, targetSlide, targetIndex) { - currentSlide.classList.add("transition-opacity", "duration-300"); - targetSlide.classList.add("transition-opacity", "duration-300"); - - currentSlide.classList.remove(...this.classes.activeSlide.split(" ")); - currentSlide.classList.add(...this.classes.hiddenSlide.split(" ")); - currentSlide.setAttribute("aria-hidden", "true"); - - targetSlide.classList.remove(...this.classes.hiddenSlide.split(" ")); - targetSlide.classList.add(...this.classes.activeSlide.split(" ")); - targetSlide.setAttribute("aria-hidden", "false"); - - this.updateIndicators(targetIndex); - - this.activeIndex = targetIndex; - - if (this.shouldFocusSlide) { - targetSlide.focus({ preventScroll: true }); - } - }, - - updateIndicators(targetIndex) { - this.indicators?.forEach((indicator, index) => { - const isActive = index + 1 === targetIndex; - - if (isActive) { - indicator.classList.add(this.classes.activeIndicator); - indicator.setAttribute("aria-current", "true"); - } else { - indicator.classList.remove(this.classes.activeIndicator); - indicator.setAttribute("aria-current", "false"); - } - - indicator.setAttribute("aria-label", `Go to slide ${index + 1}`); - }); - }, - - selectSlide(index) { - this.handleIndicatorClick(index); - }, - - setupAutoplay() { - if (this.shouldAutoplay) { - this.startAutoplay(); - } - }, - - startAutoplay() { - if (this.isHovered && this.el.dataset.pauseOnHover !== "false") return; - if (this.isTouching) return; - - clearInterval(this.autoplayTimer); - this.autoplayTimer = setInterval(() => { - const nextIndex = - this.activeIndex < this.totalSlides ? this.activeIndex + 1 : 1; - this.navigateToSlide(nextIndex); - }, this.autoplayInterval); - }, - - stopAutoplay() { - clearInterval(this.autoplayTimer); - this.autoplayTimer = null; - }, - - handleUserInteraction() { - this.stopAutoplay(); - clearTimeout(this.userInteractionTimeout); - this.userInteractionTimeout = setTimeout(() => { - if (this.shouldAutoplay && !this.isHovered && !this.isTouching) { - this.startAutoplay(); - } - }, this.userInteractionDelay); - }, - - showInitialSlide() { - this.slides.forEach((slide, index) => { - const isActive = index + 1 === this.activeIndex; - this.toggleSlideVisibility(slide, isActive); - if (this.indicators[index]) { - this.toggleIndicatorState(this.indicators[index], isActive); - } - }); - }, - - toggleSlideVisibility(slide, isActive) { - if (isActive) { - slide.classList.add(...this.classes.activeSlide.split(" ")); - slide.classList.remove(...this.classes.hiddenSlide.split(" ")); - slide.setAttribute("aria-hidden", "false"); - slide.setAttribute("tabindex", "0"); - } else { - slide.classList.remove(...this.classes.activeSlide.split(" ")); - slide.classList.add(...this.classes.hiddenSlide.split(" ")); - slide.setAttribute("aria-hidden", "true"); - slide.setAttribute("tabindex", "-1"); - } - }, - - toggleIndicatorState(indicator, isActive) { - if (isActive) { - indicator.classList.add(this.classes.activeIndicator); - indicator.setAttribute("aria-current", "true"); - } else { - indicator.classList.remove(this.classes.activeIndicator); - indicator.setAttribute("aria-current", "false"); - } - }, - - setupImageLoadHandling() { - this.imageLoaders.forEach((img) => { - const overlay = this.createLoadingOverlay(img); - if (!overlay) return; - - const slide = img.closest(".slide"); - if (slide) { - slide.style.minHeight = "300px"; - overlay.style.minHeight = "300px"; - } - - img.parentNode.insertBefore(overlay, img.nextSibling); - - if (img.complete && img.naturalWidth > 0) { - overlay.remove(); - if (slide) { - slide.style.removeProperty("min-height"); - } - } else { - if ( - "loading" in HTMLImageElement.prototype && - !img.hasAttribute("loading") - ) { - img.loading = "lazy"; - } - - img.addEventListener( - "load", - () => { - overlay.classList.add("fade-out"); - setTimeout(() => overlay.remove(), 300); - if (slide) { - slide.style.removeProperty("min-height"); - } - }, - { once: true }, - ); - - img.addEventListener( - "error", - () => { - overlay.textContent = ""; - const errorDiv = document.createElement("div"); - errorDiv.className = "text-red-500 py-10"; - errorDiv.textContent = "Failed to load image"; - overlay.appendChild(errorDiv); - overlay.classList.remove("animate-pulse"); - }, - { once: true }, - ); - } - }); - }, - - createLoadingOverlay(img) { - if (!img) return null; - - const overlay = document.createElement("div"); - overlay.className = - "absolute inset-0 bg-gray-200 dark:bg-gray-800 animate-pulse flex items-center justify-center transition-opacity duration-300"; - overlay.id = img.id + "-loading"; - - const spinner = document.createElement("div"); - spinner.className = - "animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"; - overlay.appendChild(spinner); - - return overlay; - }, - - showImageLoadingOverlay(img, slide) { - const existingOverlay = document.getElementById(img.id + "-loading"); - - if (!existingOverlay && img) { - const overlay = this.createLoadingOverlay(img); - if (overlay) { - slide.appendChild(overlay); - - img.addEventListener( - "load", - () => { - overlay.classList.add("fade-out"); - setTimeout(() => overlay.remove(), 300); - }, - { once: true }, - ); - } - } - }, - - // Helper method: sanitizes a number input - sanitizeNumber(value) { - const num = parseInt(value, 10); - return !isNaN(num) ? num : 0; - }, - - // Helper method: sanitizes class names by allowing only alphanumeric, dash, underscore, and space - sanitizeClassName(classString) { - if (!classString || typeof classString !== "string") return null; - return classString - .split(" ") - .map((name) => name.replace(/[^a-zA-Z0-9_\-]/g, "")) - .filter((name) => name.length > 0) - .join(" "); - }, -}; - -export default Carousel; diff --git a/assets/vendor/clipboard.js b/assets/vendor/clipboard.js deleted file mode 100644 index dc5341ae..00000000 --- a/assets/vendor/clipboard.js +++ /dev/null @@ -1,466 +0,0 @@ -/** - * Enhanced Clipboard Component - * - * A reusable component that provides clipboard functionality with improved: - * - Performance: Optimized event handling and DOM operations - * - Security: Better sanitization and error handling - * - Organization: Cleaner code structure with the original object pattern - * - Accessibility: Enhanced ARIA support - */ -const Clipboard = { - /** - * Initialize the clipboard component - */ - mounted() { - // Prevent double initialization - if (this._alreadyMounted) return; - this._alreadyMounted = true; - // Setup initial state - this.initializeConfig(); - this.initializeElements(); - this.setupEventListeners(); - }, - - /** - * Initialize and cache DOM elements - */ - initializeElements() { - this.statusEl = this.el.querySelector(".clipboard-status"); - this.triggerWrapper = this.el.querySelector(".clipboard-trigger"); - this.triggerEl = - this.triggerWrapper?.firstElementChild || this.triggerWrapper; - - if (!this.triggerEl) return; - - // Setup accessibility attributes - if (!this.triggerEl.getAttribute("tabindex")) { - this.triggerEl.setAttribute("tabindex", "0"); - } - - if (!this.triggerEl.getAttribute("role")) { - this.triggerEl.setAttribute("role", "button"); - } - - if (!this.triggerEl.getAttribute("aria-label")) { - this.triggerEl.setAttribute("aria-label", "Copy to clipboard"); - } - - // Save original content for restoring later - this.labelEl = this.triggerEl.querySelector(".clipboard-label"); - if (this.labelEl) { - this.originalLabel = this.labelEl.textContent; - } else { - const childNodes = Array.from(this.triggerEl.childNodes); - this.originalTextNode = childNodes.find( - (node) => - node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "", - ); - - if (this.originalTextNode) { - this.originalText = this.originalTextNode.textContent; - } else { - this.originalLabelHTML = this.triggerEl.innerHTML; - } - } - }, - - /** - * Clean up resources when component is removed - */ - destroyed() { - this.removeEventListeners(); - this.clearTimers(); - }, - - /** - * Parse configuration from data attributes - */ - initializeConfig() { - const { dataset } = this.el; - - // Parse timeout with validation - const timeoutValue = parseInt(dataset.timeout, 10); - this.timeout = - !isNaN(timeoutValue) && timeoutValue > 0 ? timeoutValue : 2000; - - // Parse and sanitize configuration - this.successClass = this.sanitizeClassName( - dataset.successClass || "clipboard-success", - ); - this.errorClass = this.sanitizeClassName( - dataset.errorClass || "clipboard-error", - ); - this.successText = this.sanitizeText(dataset.copySuccessText || "Copied!"); - this.errorText = this.sanitizeText(dataset.copyErrorText || "Copy failed"); - - // Parse boolean values - this.dynamicLabel = String(dataset.dynamicLabel).toLowerCase() === "true"; - }, - - /** - * Set up event listeners - */ - setupEventListeners() { - if (!this.triggerEl) return; - - // Store bound handlers for proper cleanup - this.handleCopyClick = this.handleCopyClick.bind(this); - this.handleCopyKeydown = this.handleCopyKeydown.bind(this); - - this.triggerEl.addEventListener("click", this.handleCopyClick); - this.triggerEl.addEventListener("keydown", this.handleCopyKeydown); - }, - - /** - * Remove event listeners - */ - removeEventListeners() { - if (!this.triggerEl) return; - - if (this.handleCopyClick) { - this.triggerEl.removeEventListener("click", this.handleCopyClick); - } - - if (this.handleCopyKeydown) { - this.triggerEl.removeEventListener("keydown", this.handleCopyKeydown); - } - }, - - /** - * Clear any active timers - */ - clearTimers() { - if (this.statusReset) { - clearTimeout(this.statusReset); - this.statusReset = null; - } - }, - - /** - * Sanitize CSS class names for security - * @param {string} classNames - Space-separated list of class names - * @returns {string} - Sanitized class names - */ - sanitizeClassName(classNames) { - if (!classNames || typeof classNames !== "string") { - return "clipboard-success"; - } - - return ( - classNames - .split(" ") - .filter((name) => /^[a-zA-Z0-9_\-]+$/.test(name)) - .join(" ") || "clipboard-success" - ); - }, - - /** - * Sanitize text content - * @param {string} text - Text to sanitize - * @returns {string} - Sanitized text - */ - sanitizeText(text) { - if (!text || typeof text !== "string") { - return ""; - } - - // Limit length and escape sensitive characters - return text - .slice(0, 100) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }, - - /** - * Handle click events on the trigger element - * @param {Event} event - The click event - */ - handleCopyClick(event) { - event.preventDefault(); - event.stopPropagation(); - this.handleCopy(); - }, - - /** - * Handle keyboard events for accessibility - * @param {KeyboardEvent} event - The keyboard event - */ - handleCopyKeydown(event) { - // Handle Enter or Space key for accessibility - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - this.handleCopy(); - } - }, - - /** - * Main copy operation handler - * @returns {Promise} - Success status - */ - async handleCopy() { - const text = this.getTextToCopy(); - if (!text) { - this.showStatus(false); - return false; - } - - try { - // Use modern Clipboard API with fallback - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - this.showStatus(true); - return true; - } else { - return this.fallbackCopyTextToClipboard(text); - } - } catch (error) { - console.error("Clipboard operation failed:", error); - this.showStatus(false); - return false; - } - }, - - /** - * Legacy fallback for clipboard operations - * @param {string} text - Text to copy - * @returns {boolean} - Success status - */ - fallbackCopyTextToClipboard(text) { - const textArea = document.createElement("textarea"); - textArea.value = text; - - // Position off-screen but maintain accessibility - Object.assign(textArea.style, { - position: "absolute", - left: "-9999px", - top: "-9999px", - opacity: "0", - }); - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - let succeeded = false; - try { - succeeded = document.execCommand("copy"); - } catch (error) { - console.error("Fallback clipboard operation failed:", error); - } finally { - document.body.removeChild(textArea); - } - - this.showStatus(succeeded); - return succeeded; - }, - - /** - * Get the text to be copied based on configuration - * @returns {string|null} - Text to copy or null if not found - */ - getTextToCopy() { - const { dataset } = this.el; - - // Priority 1: Explicit text in data attribute - if (dataset.clipboardText) { - return dataset.clipboardText; - } - - // Priority 2: Text from targeted element via selector - if (dataset.targetSelector) { - try { - const el = document.querySelector(dataset.targetSelector); - return el ? this.extractText(el) : null; - } catch (error) { - console.error("Error selecting target element:", error); - return null; - } - } - - // Priority 3: Text from designated content element - const contentEl = this.el.querySelector(".clipboard-content"); - return contentEl ? this.extractText(contentEl) : null; - }, - - /** - * Extract text content from an element based on its type - * @param {HTMLElement} el - Element to extract text from - * @returns {string|null} - Extracted text or null - */ - extractText(el) { - if (!el) return null; - - // Handle form elements - if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { - return el.value; - } - - // Handle code blocks with priority - const code = el.querySelector("pre, code"); - if (code) { - return code.textContent; - } - - // Get clean text content without excessive whitespace - return el.textContent.replace(/\s+/g, " ").trim(); - }, - - /** - * Update UI to show operation status - * @param {boolean} success - Whether operation succeeded - */ - showStatus(success) { - const trigger = this.triggerEl; - const status = this.statusEl; - - // Update trigger classes - if (trigger) { - this.updateTriggerClasses(trigger, success); - - trigger.setAttribute("aria-disabled", "true"); - trigger.setAttribute("tabindex", "-1"); - - // Update label if configured - if (this.dynamicLabel) { - this.updateLabel(success); - } - } - - // Update status element - if (status) { - status.textContent = success ? this.successText : this.errorText; - status.setAttribute("aria-hidden", "false"); - - // Announce to screen readers - this.announceToScreenReader(success ? this.successText : this.errorText); - } - - // Schedule reset after timeout - this.clearTimers(); - this.statusReset = setTimeout(() => this.resetStatus(), this.timeout); - }, - - /** - * Update CSS classes on trigger element - * @param {HTMLElement} trigger - Trigger element - * @param {boolean} success - Success status - */ - updateTriggerClasses(trigger, success) { - // Remove all status classes - const allClasses = [ - ...this.successClass.split(" "), - ...this.errorClass.split(" "), - ]; - - allClasses.forEach((cls) => { - if (cls) trigger.classList.remove(cls); - }); - - // Add appropriate status classes - const newClasses = (success ? this.successClass : this.errorClass).split( - " ", - ); - newClasses.forEach((cls) => { - if (cls) trigger.classList.add(cls); - }); - }, - - /** - * Update label text based on operation status - * @param {boolean} success - Success status - */ - updateLabel(success) { - const statusText = success ? this.successText : this.errorText; - - if (this.labelEl) { - this.labelEl.textContent = statusText; - } else if (this.originalTextNode) { - this.originalTextNode.textContent = statusText; - } else { - this.triggerEl.innerHTML = statusText; - } - }, - - /** - * Announce status to screen readers - * @param {string} message - Message to announce - */ - announceToScreenReader(message) { - let liveRegion = document.getElementById("clipboard-live-region"); - - if (!liveRegion) { - liveRegion = document.createElement("div"); - liveRegion.id = "clipboard-live-region"; - liveRegion.setAttribute("aria-live", "polite"); - liveRegion.setAttribute("aria-atomic", "true"); - liveRegion.className = "sr-only"; - - Object.assign(liveRegion.style, { - position: "absolute", - width: "1px", - height: "1px", - padding: "0", - margin: "-1px", - overflow: "hidden", - clip: "rect(0, 0, 0, 0)", - whiteSpace: "nowrap", - border: "0", - }); - - document.body.appendChild(liveRegion); - } - - liveRegion.textContent = message; - }, - - /** - * Reset UI state after timeout - */ - resetStatus() { - const trigger = this.triggerEl; - const status = this.statusEl; - - // Reset status display - if (status) { - status.textContent = ""; - status.setAttribute("aria-hidden", "true"); - } - - // Remove all status classes - if (trigger) { - trigger.removeAttribute("aria-disabled"); - trigger.setAttribute("tabindex", "0"); - - const allClasses = [ - ...this.successClass.split(" "), - ...this.errorClass.split(" "), - ]; - - allClasses.forEach((cls) => { - if (cls) trigger.classList.remove(cls); - }); - } - - // Reset label to original - if (this.dynamicLabel) { - if (this.labelEl && this.originalLabel) { - this.labelEl.textContent = this.originalLabel; - } else if (this.originalTextNode && this.originalText) { - this.originalTextNode.textContent = this.originalText; - } else if (this.originalLabelHTML) { - this.triggerEl.innerHTML = this.originalLabelHTML; - } - } - - // Clear screen reader announcement - let liveRegion = document.getElementById("clipboard-live-region"); - if (liveRegion) { - liveRegion.textContent = ""; - } - }, -}; - -export default Clipboard; diff --git a/assets/vendor/collapsible.js b/assets/vendor/collapsible.js deleted file mode 100644 index 15b3f881..00000000 --- a/assets/vendor/collapsible.js +++ /dev/null @@ -1,307 +0,0 @@ -const Collapsible = { - mounted() { - this.initElements(); - this.setupEventListeners(); - this.config = this.getConfig(); - this.state = { - openItems: new Set(), - animating: new Set(), - reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)") - .matches, - }; - - this.setupAccessibility(); - this.processInitialState(); - }, - - initElements() { - this.triggers = this.el.querySelectorAll("[data-collapsible-trigger]"); - this.panels = this.el.querySelectorAll("[data-collapsible-panel]"); - - // Create mapping for easy lookup - this.itemMap = new Map(); - this.triggers.forEach((trigger) => { - const itemId = trigger.getAttribute("data-collapsible-trigger"); - const panel = this.el.querySelector( - `[data-collapsible-panel="${itemId}"]`, - ); - if (panel) { - this.itemMap.set(itemId, { trigger, panel }); - } - }); - }, - - getConfig() { - return { - multiple: this.el.getAttribute("data-multiple") === "true", - collapsible: this.el.getAttribute("data-collapsible") !== "false", - duration: parseInt(this.el.getAttribute("data-duration")) || 200, - keepMounted: this.el.getAttribute("data-keep-mounted") === "true", - serverEvents: this.el.getAttribute("data-server-events") === "true", - eventHandler: this.el.getAttribute("data-event-handler"), - }; - }, - - setupAccessibility() { - const collapsibleId = - this.el.id || `collapsible-${Math.random().toString(36).substring(2, 9)}`; - this.el.id = collapsibleId; - - this.itemMap.forEach((elements, itemId) => { - const { trigger, panel } = elements; - const headerId = `${collapsibleId}-header-${itemId}`; - const panelId = `${collapsibleId}-panel-${itemId}`; - - trigger.setAttribute("role", "button"); - trigger.setAttribute("aria-controls", panelId); - trigger.setAttribute("aria-expanded", "false"); - trigger.setAttribute("id", headerId); - trigger.setAttribute("tabindex", "0"); - - panel.setAttribute("role", "region"); - panel.setAttribute("aria-labelledby", headerId); - panel.setAttribute("id", panelId); - }); - }, - - setupEventListeners() { - this.boundHandleClick = this.handleClick.bind(this); - this.boundHandleKeydown = this.handleKeydown.bind(this); - - this.el.addEventListener("click", this.boundHandleClick); - this.el.addEventListener("keydown", this.boundHandleKeydown); - }, - - processInitialState() { - const initialOpen = this.el.getAttribute("data-initial-open"); - - // Clear all items first - this.state.openItems.clear(); - - // Only process if we have a non-empty initial-open value - if (initialOpen && initialOpen.trim() !== "") { - const openIds = initialOpen - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - openIds.forEach((id) => { - // Verify the item actually exists before adding it - if (this.itemMap.has(id)) { - this.state.openItems.add(id); - } - }); - } - - // Always sync UI to ensure consistent state - this.syncUI(); - }, - - handleClick(e) { - const trigger = e.target.closest("[data-collapsible-trigger]"); - if (!trigger || !this.el.contains(trigger)) return; - - e.preventDefault(); - const itemId = trigger.getAttribute("data-collapsible-trigger"); - this.toggle(itemId); - }, - - handleKeydown(e) { - const trigger = e.target.closest("[data-collapsible-trigger]"); - if (!trigger || !this.el.contains(trigger)) return; - - const itemId = trigger.getAttribute("data-collapsible-trigger"); - const triggers = Array.from(this.triggers); - const currentIndex = triggers.indexOf(trigger); - - switch (e.key) { - case " ": - case "Enter": - e.preventDefault(); - this.toggle(itemId); - break; - case "ArrowDown": - e.preventDefault(); - this.focusNextTrigger(currentIndex, triggers); - break; - case "ArrowUp": - e.preventDefault(); - this.focusPrevTrigger(currentIndex, triggers); - break; - case "Home": - e.preventDefault(); - triggers[0]?.focus(); - break; - case "End": - e.preventDefault(); - triggers[triggers.length - 1]?.focus(); - break; - } - }, - - focusNextTrigger(currentIndex, triggers) { - const nextIndex = currentIndex < triggers.length - 1 ? currentIndex + 1 : 0; - triggers[nextIndex]?.focus(); - }, - - focusPrevTrigger(currentIndex, triggers) { - const prevIndex = currentIndex > 0 ? currentIndex - 1 : triggers.length - 1; - triggers[prevIndex]?.focus(); - }, - - toggle(itemId) { - if (this.state.animating.has(itemId)) return; - - if (this.state.openItems.has(itemId)) { - this.close(itemId); - } else { - this.open(itemId); - } - }, - - open(itemId) { - if (this.state.openItems.has(itemId)) return; - - if (!this.config.multiple) { - Array.from(this.state.openItems).forEach((id) => this.close(id)); - } - - this.state.openItems.add(itemId); - this.animatePanel(itemId, true); - this.updateAria(itemId, true); - this.pushServerEvent("collapsible_open", itemId); - }, - - close(itemId) { - if (!this.state.openItems.has(itemId)) return; - if (!this.config.collapsible && this.state.openItems.size === 1) return; - - this.state.openItems.delete(itemId); - this.animatePanel(itemId, false); - this.updateAria(itemId, false); - this.pushServerEvent("collapsible_close", itemId); - }, - - animatePanel(itemId, opening) { - const elements = this.itemMap.get(itemId); - if (!elements) return; - - const { panel } = elements; - const content = panel.querySelector("[data-collapsible-content]") || panel; - - if (this.state.reducedMotion) { - content.style.maxHeight = opening ? "none" : "0"; - content.style.overflow = opening ? "visible" : "hidden"; - return; - } - - this.state.animating.add(itemId); - - if (opening) { - content.style.overflow = "hidden"; - content.style.maxHeight = "0"; - - requestAnimationFrame(() => { - const height = content.scrollHeight; - content.style.maxHeight = `${height}px`; - - setTimeout(() => { - if (this.state.openItems.has(itemId)) { - content.style.maxHeight = "none"; - content.style.overflow = "visible"; - } - this.state.animating.delete(itemId); - }, this.config.duration); - }); - } else { - const height = content.scrollHeight; - content.style.overflow = "hidden"; - content.style.maxHeight = `${height}px`; - - requestAnimationFrame(() => { - content.style.maxHeight = "0"; - - setTimeout(() => { - this.state.animating.delete(itemId); - }, this.config.duration); - }); - } - }, - - updateAria(itemId, expanded) { - const elements = this.itemMap.get(itemId); - if (!elements) return; - - elements.trigger.setAttribute("aria-expanded", expanded.toString()); - }, - - syncUI() { - this.itemMap.forEach((elements, itemId) => { - const isOpen = this.state.openItems.has(itemId); - const { panel } = elements; - const content = - panel.querySelector("[data-collapsible-content]") || panel; - - this.updateAria(itemId, isOpen); - - if (isOpen) { - content.style.maxHeight = "none"; - content.style.overflow = "visible"; - } else { - content.style.maxHeight = "0"; - content.style.overflow = "hidden"; - } - }); - }, - - pushServerEvent(event, itemId) { - if (!this.config.serverEvents) return; - - const payload = { - component_id: this.el.id, - item_id: itemId, - action: event === "collapsible_open" ? "open" : "close", - open_ids: Array.from(this.state.openItems), - }; - - if (this.config.eventHandler) { - this.pushEvent(this.config.eventHandler, payload); - } else { - this.pushEvent(event, payload); - } - }, - - // Public API - getOpenIds() { - return Array.from(this.state.openItems); - }, - - openItem(itemId) { - this.open(itemId); - }, - - closeItem(itemId) { - this.close(itemId); - }, - - toggleItem(itemId) { - this.toggle(itemId); - }, - - updated() { - // Re-initialize elements in case DOM changed - this.initElements(); - // Process any changes to initial state - this.processInitialState(); - }, - - destroyed() { - if (this.el && this.boundHandleClick) { - this.el.removeEventListener("click", this.boundHandleClick); - this.el.removeEventListener("keydown", this.boundHandleKeydown); - } - this.boundHandleClick = null; - this.boundHandleKeydown = null; - }, -}; - -export default Collapsible; diff --git a/assets/vendor/combobox.js b/assets/vendor/combobox.js deleted file mode 100644 index 08a65093..00000000 --- a/assets/vendor/combobox.js +++ /dev/null @@ -1,707 +0,0 @@ -let Combobox = { - mounted() { - this.initElements(); - - this.boundHandleScroll = this.updateDropdownPosition.bind(this); - this.boundHandleKeyDown = this.handleKeyDown.bind(this); - this.boundHandleDocumentClick = this.handleDocumentClick.bind(this); - this.boundOpenButtonClick = this.handleOpenButtonClick.bind(this); - this.boundOptionClickHandlers = []; - this.boundClearButtonClick = this.handleClearButtonClick.bind(this); - - this.lastNavigatedValue = null; - this.originalParent = null; - this.isPortalActive = false; - - if (!this.openButton.id) { - this.openButton.id = `combobox-trigger-${Math.random() - .toString(36) - .substring(2, 9)}`; - } - - this.openButton.addEventListener("click", this.boundOpenButtonClick); - this.openButton.addEventListener("keydown", this.boundHandleKeyDown); - document.addEventListener("keydown", this.boundHandleKeyDown); - - this.setupOptionListeners(); - - if (this.searchInput) { - this.boundSearchInputHandler = this.handleSearch.bind(this); - this.searchInput.addEventListener("input", this.boundSearchInputHandler); - this.searchInput.addEventListener("keydown", this.boundHandleKeyDown); - } - - if (this.clearButton) { - this.clearButton.addEventListener("click", this.boundClearButtonClick); - } - - document.addEventListener("click", this.boundHandleDocumentClick, true); - - this.observer = new MutationObserver(() => { - this.syncDisplayFromSelect(); - }); - this.observer.observe(this.select, { - attributes: true, - childList: true, - subtree: true, - }); - - this.syncDisplayFromSelect(); - }, - - initElements() { - this.select = this.el.querySelector(".combo-select"); - this.dropdown = this.el.querySelector('[data-part="listbox"]'); - this.openButton = this.el.querySelector(".combobox-trigger"); - this.selectedDisplay = this.el.querySelector(".selected-value"); - this.searchInput = this.el.querySelector(".combobox-search-input"); - this.clearButton = this.el.querySelector( - '[data-part="clear-combobox-button"]', - ); - this.dropdownOptions = this.getDropdownOptions(); - }, - - getDropdownOptions() { - if (this.isPortalActive && this.portalContainer) { - return this.portalContainer.querySelectorAll(".combobox-option"); - } - return this.el.querySelectorAll(".combobox-option"); - }, - - setupOptionListeners() { - this.boundOptionClickHandlers.forEach(({ btn, handler }) => { - btn.removeEventListener("click", handler); - }); - this.boundOptionClickHandlers = []; - - this.getDropdownOptions().forEach((btn) => { - const handler = this.handleOptionClick.bind(this); - btn.addEventListener("click", handler); - this.boundOptionClickHandlers.push({ btn, handler }); - }); - }, - - createPortalIfNeeded() { - if (!this.portalContainer) { - this.portalContainer = document.createElement("div"); - this.portalContainer.id = `combobox-portal-${this.openButton.id}`; - this.portalContainer.style.cssText = ` - position: fixed; - top: 0; - left: 0; - z-index: 9999; - pointer-events: none; - `; - document.body.appendChild(this.portalContainer); - } - }, - - moveDropdownToPortal() { - if (!this.isPortalActive && this.dropdown) { - this.createPortalIfNeeded(); - this.originalParent = this.dropdown.parentNode; - - const rect = this.openButton.getBoundingClientRect(); - const dropdownHeight = this.dropdown.offsetHeight || 200; - const windowHeight = window.innerHeight; - const spaceBelow = windowHeight - rect.bottom; - const shouldShowAbove = - spaceBelow < dropdownHeight && rect.top > dropdownHeight; - - this.portalContainer.appendChild(this.dropdown); - this.portalContainer.style.pointerEvents = "auto"; - this.isPortalActive = true; - - this.setupOptionListeners(); - - this.dropdown.style.position = "absolute"; - this.dropdown.style.top = shouldShowAbove - ? `${rect.top - dropdownHeight - 8}px` - : `${rect.bottom + 8}px`; - this.dropdown.style.left = `${rect.left}px`; - this.dropdown.style.width = `${rect.width}px`; - - this.dropdown.classList.remove("top-full", "mt-2", "bottom-full", "mb-2"); - } - }, - - moveDropdownBack() { - if (this.isPortalActive && this.dropdown && this.originalParent) { - this.originalParent.appendChild(this.dropdown); - this.isPortalActive = false; - if (this.portalContainer) { - this.portalContainer.style.pointerEvents = "none"; - } - - this.setupOptionListeners(); - - this.dropdown.style.position = ""; - this.dropdown.style.top = ""; - this.dropdown.style.left = ""; - } - }, - - checkForOverflowHidden() { - let element = this.el.parentElement; - while (element && element !== document.body) { - const computedStyle = window.getComputedStyle(element); - if ( - computedStyle.overflow === "hidden" || - computedStyle.overflowX === "hidden" || - computedStyle.overflowY === "hidden" - ) { - return true; - } - element = element.parentElement; - } - return false; - }, - - handleOpenButtonClick(e) { - e.preventDefault(); - e.stopPropagation(); - if (this.dropdown.hasAttribute("hidden")) { - this.openDropdown(); - } else { - this.closeDropdown(); - } - }, - - openDropdown() { - const hasOverflowHidden = this.checkForOverflowHidden(); - if (hasOverflowHidden) { - this.moveDropdownToPortal(); - } - - this.dropdown.removeAttribute("hidden"); - this.openButton.setAttribute("aria-expanded", "true"); - - requestAnimationFrame(() => { - this.updateDropdownPosition(); - - this.dropdownOptions = this.getDropdownOptions(); - - let navigateTarget = null; - if (this.lastNavigatedValue) { - navigateTarget = Array.from(this.dropdownOptions).find( - (opt) => opt.dataset.comboboxValue === this.lastNavigatedValue, - ); - } - if (!navigateTarget) { - navigateTarget = Array.from(this.dropdownOptions).find((opt) => - opt.hasAttribute("data-combobox-selected"), - ); - } - - if (!navigateTarget) { - const visibleOptions = Array.from(this.dropdownOptions).filter( - (opt) => opt.style.display !== "none", - ); - if (visibleOptions.length > 0) { - navigateTarget = visibleOptions[0]; - } - } - - Array.from(this.dropdownOptions).forEach((opt) => - opt.removeAttribute("data-combobox-navigate"), - ); - - if (navigateTarget) { - this.navigateToOption(navigateTarget); - } else { - this.openButton.removeAttribute("aria-activedescendant"); - } - - if (this.searchInput) { - this.searchInput.focus(); - } else { - this.openButton.focus(); - } - }); - - window.addEventListener("scroll", this.boundHandleScroll, { - passive: true, - }); - }, - - closeDropdown() { - if (!this.dropdown.hasAttribute("hidden")) { - this.dropdown.setAttribute("hidden", true); - } - this.openButton.setAttribute("aria-expanded", "false"); - this.openButton.removeAttribute("aria-activedescendant"); - window.removeEventListener("scroll", this.boundHandleScroll); - document.removeEventListener("keydown", this.boundHandleKeyDown); - - this.moveDropdownBack(); - }, - - navigateToOption(option) { - if (!option) return; - - Array.from(this.getDropdownOptions()).forEach((opt) => { - opt.removeAttribute("data-combobox-navigate"); - }); - - option.setAttribute("data-combobox-navigate", ""); - option.scrollIntoView({ block: "nearest" }); - this.lastNavigatedValue = option.dataset.comboboxValue; - - if (option.id) { - this.openButton.setAttribute("aria-activedescendant", option.id); - } else { - this.openButton.removeAttribute("aria-activedescendant"); - } - }, - - resetNavigateToFirstOption() { - const visibleOptions = Array.from(this.getDropdownOptions()).filter( - (opt) => opt.style.display !== "none", - ); - - if (visibleOptions.length > 0) { - this.navigateToOption(visibleOptions[0]); - } - }, - - updateDropdownPosition() { - const rect = this.openButton.getBoundingClientRect(); - const dropdownHeight = this.dropdown.offsetHeight || 200; - const windowHeight = window.innerHeight; - const spaceBelow = windowHeight - rect.bottom; - - if (this.isPortalActive) { - const shouldShowAbove = - spaceBelow < dropdownHeight && rect.top > dropdownHeight; - - if (shouldShowAbove) { - this.dropdown.style.top = `${rect.top - dropdownHeight - 8}px`; - } else { - this.dropdown.style.top = `${rect.bottom + 8}px`; - } - - this.dropdown.style.left = `${rect.left}px`; - this.dropdown.style.width = `${rect.width}px`; - - this.dropdown.classList.remove("top-full", "mt-2", "bottom-full", "mb-2"); - } else { - if (spaceBelow < dropdownHeight) { - this.dropdown.classList.remove("top-full", "mt-2"); - this.dropdown.classList.add("bottom-full", "mb-2"); - } else { - this.dropdown.classList.remove("bottom-full", "mb-2"); - this.dropdown.classList.add("top-full", "mt-2"); - } - } - }, - - handleSearch(e) { - const query = e.target.value.toLowerCase(); - this.dropdownOptions = this.getDropdownOptions(); - - Array.from(this.dropdownOptions).forEach((option) => { - const valueAttr = option - .getAttribute("data-combobox-value") - .toLowerCase(); - const displayText = option.textContent.trim().toLowerCase(); - const matches = valueAttr.includes(query) || displayText.includes(query); - option.style.display = matches ? "" : "none"; - }); - - const noResults = this.dropdown.querySelector(".no-results"); - const visibleOptions = Array.from(this.dropdownOptions).filter( - (option) => option.style.display !== "none", - ); - if (visibleOptions.length === 0) { - if (noResults) noResults.classList.remove("hidden"); - } else { - if (noResults) noResults.classList.add("hidden"); - } - - const optionGroups = this.dropdown.querySelectorAll(".option-group"); - optionGroups.forEach((group) => { - const visibleInGroup = group.querySelectorAll( - '.combobox-option:not([style*="display: none"])', - ); - group.style.display = visibleInGroup.length === 0 ? "none" : ""; - }); - - this.resetNavigateToFirstOption(); - }, - - handleOptionClick(e) { - e.preventDefault(); - const optionEl = e.target.closest(".combobox-option"); - const value = optionEl.dataset.comboboxValue; - const isMultiple = this.select.multiple; - - this.lastNavigatedValue = value; - - if (isMultiple) { - this.toggleOption(value, optionEl); - this.updateMultipleSelectedDisplay(); - this.dispatchChangeEvent(); - if (this.searchInput) { - setTimeout(() => { - this.searchInput.focus(); - }, 0); - } - } else { - Array.from(this.getDropdownOptions()).forEach((opt) => { - opt.removeAttribute("data-combobox-selected"); - opt.setAttribute("aria-selected", "false"); - }); - this.selectSingleOption(value); - optionEl.setAttribute("data-combobox-selected", ""); - optionEl.setAttribute("aria-selected", "true"); - this.closeDropdown(); - this.openButton.focus(); - } - }, - - toggleOption(value, optionEl) { - const option = Array.from(this.select.options).find( - (opt) => opt.value === value, - ); - if (option) { - option.selected = !option.selected; - if (option.selected) { - option.setAttribute("selected", ""); - optionEl.setAttribute("data-combobox-selected", ""); - optionEl.setAttribute("aria-selected", "true"); - } else { - option.removeAttribute("selected"); - optionEl.removeAttribute("data-combobox-selected"); - optionEl.setAttribute("aria-selected", "false"); - } - } - }, - - selectSingleOption(value) { - Array.from(this.select.options).forEach((opt) => { - opt.selected = opt.value === value; - }); - this.updateSingleSelectedDisplay(); - this.dispatchChangeEvent(); - }, - - updateSingleSelectedDisplay() { - Array.from(this.getDropdownOptions()).forEach((opt) => { - opt.removeAttribute("data-combobox-selected"); - opt.setAttribute("aria-selected", "false"); - }); - - const selectedOption = Array.from(this.select.options).find( - (opt) => opt.selected && opt.value !== "", - ); - const placeholder = this.el.querySelector(".combobox-placeholder"); - const clearBtn = this.el.querySelector( - '[data-part="clear-combobox-button"]', - ); - - if (selectedOption) { - if (placeholder) placeholder.style.display = "none"; - const renderedOption = Array.from(this.getDropdownOptions()).find( - (opt) => opt.dataset.comboboxValue === selectedOption.value, - ); - this.selectedDisplay.innerHTML = renderedOption - ? renderedOption.innerHTML - : selectedOption.textContent; - if (renderedOption) { - renderedOption.setAttribute("data-combobox-selected", ""); - renderedOption.setAttribute("aria-selected", "true"); - } - if (clearBtn) clearBtn.hidden = false; - } else { - if (placeholder) placeholder.style.display = ""; - this.selectedDisplay.textContent = ""; - if (clearBtn) clearBtn.hidden = true; - this.lastNavigatedValue = null; - } - }, - - updateMultipleSelectedDisplay() { - this.selectedDisplay.innerHTML = ""; - const selectedOptions = Array.from(this.select.options).filter( - (opt) => opt.selected, - ); - const placeholder = this.el.querySelector(".combobox-placeholder"); - const clearBtn = this.el.querySelector( - '[data-part="clear-combobox-button"]', - ); - - if (selectedOptions.length > 0) { - placeholder.style.display = "none"; - if (clearBtn) clearBtn.hidden = false; - } else { - placeholder.style.display = ""; - if (clearBtn) clearBtn.hidden = true; - } - - selectedOptions.forEach((option) => { - const optionEl = Array.from(this.getDropdownOptions()).find( - (opt) => opt.dataset.comboboxValue === option.value, - ); - if (optionEl) { - optionEl.setAttribute("data-combobox-selected", ""); - optionEl.setAttribute("aria-selected", "true"); - } - }); - - selectedOptions.forEach((option) => { - const pill = document.createElement("span"); - pill.classList.add( - "selected-item", - "flex", - "items-center", - "gap-2", - "combobox-pill", - ); - - const renderedOption = Array.from(this.getDropdownOptions()).find( - (opt) => opt.dataset.comboboxValue === option.value, - ); - if (renderedOption) { - pill.innerHTML = renderedOption.innerHTML; - } else { - pill.textContent = option.textContent; - } - - const closeBtn = document.createElement("span"); - closeBtn.innerHTML = - ''; - closeBtn.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - option.selected = false; - option.removeAttribute("selected"); - const optionEl = Array.from(this.getDropdownOptions()).find( - (opt) => opt.dataset.comboboxValue === option.value, - ); - if (optionEl) { - optionEl.removeAttribute("data-combobox-selected"); - optionEl.setAttribute("aria-selected", "false"); - } - this.updateMultipleSelectedDisplay(); - this.dispatchChangeEvent(); - }); - pill.appendChild(closeBtn); - this.selectedDisplay.appendChild(pill); - }); - }, - - handleKeyDown(e) { - const key = e.key; - - if ( - this.dropdown.hasAttribute("hidden") && - document.activeElement === this.openButton - ) { - if (key === " " || key === "Enter") { - e.preventDefault(); - this.openDropdown(); - return; - } - return; - } - - if (this.dropdown.hasAttribute("hidden")) return; - - if (key === "Escape") { - this.closeDropdown(); - this.openButton.focus(); - return; - } - - const isNavigationKey = [ - "ArrowDown", - "ArrowUp", - "Enter", - "Tab", - "Escape", - ].includes(key); - - if ( - this.searchInput && - document.activeElement === this.searchInput && - !isNavigationKey && - key.length === 1 - ) { - return; - } - - const visibleOptions = Array.from(this.getDropdownOptions()).filter( - (opt) => opt.style.display !== "none", - ); - - if (visibleOptions.length === 0) return; - - let currentIndex = visibleOptions.findIndex((opt) => - opt.hasAttribute("data-combobox-navigate"), - ); - - if (key === "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - currentIndex = - currentIndex < 0 ? 0 : (currentIndex + 1) % visibleOptions.length; - this.navigateToOption(visibleOptions[currentIndex]); - return; - } else if (key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - currentIndex = - currentIndex < 0 - ? visibleOptions.length - 1 - : (currentIndex - 1 + visibleOptions.length) % visibleOptions.length; - this.navigateToOption(visibleOptions[currentIndex]); - return; - } else if (key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - if (currentIndex >= 0) { - const targetOption = visibleOptions[currentIndex]; - const value = targetOption.dataset.comboboxValue; - this.lastNavigatedValue = value; - - if (this.select.multiple) { - this.toggleOption(value, targetOption); - this.updateMultipleSelectedDisplay(); - this.dispatchChangeEvent(); - this.navigateToOption(targetOption); - } else { - Array.from(this.getDropdownOptions()).forEach((opt) => { - opt.removeAttribute("data-combobox-selected"); - opt.setAttribute("aria-selected", "false"); - }); - this.selectSingleOption(value); - targetOption.setAttribute("data-combobox-selected", ""); - targetOption.setAttribute("aria-selected", "true"); - this.closeDropdown(); - this.openButton.focus(); - } - } - return; - } else if (key === "Tab") { - this.closeDropdown(); - return; - } else if (key.length === 1) { - e.preventDefault(); - this.handleCharacterNavigation(key.toLowerCase(), visibleOptions); - return; - } else { - return; - } - }, - - handleCharacterNavigation(char, options) { - const matchingOptions = options.filter((opt) => { - const valueAttr = opt.getAttribute("data-combobox-value") || ""; - const labelText = opt.textContent.trim().toLowerCase(); - return ( - valueAttr.toLowerCase().startsWith(char) || labelText.startsWith(char) - ); - }); - if (matchingOptions.length === 0) return; - - let currentIndex = matchingOptions.findIndex((opt) => - opt.hasAttribute("data-combobox-navigate"), - ); - currentIndex = (currentIndex + 1) % matchingOptions.length; - - this.navigateToOption(matchingOptions[currentIndex]); - }, - - syncDisplayFromSelect() { - Array.from(this.getDropdownOptions()).forEach((opt) => { - opt.removeAttribute("data-combobox-selected"); - opt.setAttribute("aria-selected", "false"); - }); - - if (this.select.multiple) { - this.updateMultipleSelectedDisplay(); - } else { - this.updateSingleSelectedDisplay(); - } - }, - - handleDocumentClick(e) { - if ( - !this.el.contains(e.target) && - !( - this.isPortalActive && - this.portalContainer && - this.portalContainer.contains(e.target) - ) && - !this.isClickOnScrollbar(e) - ) { - this.closeDropdown(); - } - }, - - isClickOnScrollbar(e) { - return ( - e.clientX >= document.documentElement.clientWidth || - e.clientY >= document.documentElement.clientHeight - ); - }, - - dispatchChangeEvent() { - const changeEvent = new Event("change", { bubbles: true }); - this.select.dispatchEvent(changeEvent); - }, - - handleClearButtonClick(e) { - e.preventDefault(); - e.stopPropagation(); - Array.from(this.select.options).forEach((opt) => { - opt.selected = false; - opt.removeAttribute("selected"); - }); - Array.from(this.getDropdownOptions()).forEach((opt) => { - opt.removeAttribute("data-combobox-selected"); - opt.setAttribute("aria-selected", "false"); - }); - this.syncDisplayFromSelect(); - this.closeDropdown(); - this.dispatchChangeEvent(); - this.resetNavigateToFirstOption(); - }, - - destroyed() { - this.closeDropdown(); - - if (this.portalContainer && this.portalContainer.parentNode) { - this.portalContainer.parentNode.removeChild(this.portalContainer); - } - - if (this.observer) { - this.observer.disconnect(); - } - document.removeEventListener("click", this.boundHandleDocumentClick, true); - if (this.openButton && this.boundOpenButtonClick) { - this.openButton.removeEventListener("click", this.boundOpenButtonClick); - this.openButton.removeEventListener("keydown", this.boundHandleKeyDown); - } - if ( - this.boundOptionClickHandlers && - this.boundOptionClickHandlers.length > 0 - ) { - this.boundOptionClickHandlers.forEach(({ btn, handler }) => { - btn.removeEventListener("click", handler); - }); - } - if (this.searchInput && this.boundSearchInputHandler) { - this.searchInput.removeEventListener( - "input", - this.boundSearchInputHandler, - ); - this.searchInput.removeEventListener("keydown", this.boundHandleKeyDown); - } - if (this.clearButton && this.boundClearButtonClick) { - this.clearButton.removeEventListener("click", this.boundClearButtonClick); - } - }, -}; - -export default Combobox; diff --git a/assets/vendor/floating.js b/assets/vendor/floating.js deleted file mode 100644 index dd187dd4..00000000 --- a/assets/vendor/floating.js +++ /dev/null @@ -1,593 +0,0 @@ -const Floating = { - mounted() { - this.initElements(); - - this.movedToBody = false; - - this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); - this.boundHandleKeydown = this.handleKeydown.bind(this); - this.boundHandleClick = this.handleClick.bind(this); - this.boundHandleMouseEnter = this.handleMouseEnter.bind(this); - this.boundHandleMouseLeave = this.handleMouseLeave.bind(this); - this.boundUpdatePosition = this.updatePosition.bind(this); - - this.enableAria = this.el.getAttribute("data-enable-aria") !== "false"; - this.smartPositioning = - this.el.getAttribute("data-smart-position") === "true"; - this.clickable = this.el.getAttribute("data-clickable") === "true"; - this.position = this.el.getAttribute("data-position") || "bottom"; - - this.showDelay = parseInt(this.el.getAttribute("data-show-delay")) || 0; - this.hideDelay = parseInt(this.el.getAttribute("data-hide-delay")) || 400; - - this.showTimeout = null; - this.hideTimeout = null; - - this.floatingType = this.getFloatingType(); - - this.isRTL = getComputedStyle(document.documentElement).direction === "rtl"; - - this.cleanupDuplicateIds(); - - this.setupFloatingContent(); - this.setupEventListeners(); - - this.forcedWidth = null; - this.updatePosition(); - }, - - cleanupDuplicateIds() { - if (this.floatingContent && this.floatingContent.id) { - const duplicates = document.body.querySelectorAll( - `#${this.floatingContent.id}`, - ); - duplicates.forEach((duplicate) => { - if ( - duplicate !== this.floatingContent && - duplicate.parentNode === document.body - ) { - try { - document.body.removeChild(duplicate); - } catch (e) {} - } - }); - } - }, - - setVisibility(element, visible) { - if (!element) return; - - if (visible) { - element.removeAttribute("hidden"); - element.classList.remove("invisible", "opacity-0"); - element.classList.add("visible", "opacity-100", "show-dropdown"); - } else { - element.classList.remove("visible", "opacity-100", "show-dropdown"); - element.classList.add("invisible", "opacity-0"); - element.setAttribute("hidden", ""); - } - }, - - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - }, - - beforeUpdate() { - if (this.showTimeout) { - clearTimeout(this.showTimeout); - this.showTimeout = null; - } - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - this.hideTimeout = null; - } - - if ( - this.floatingContent && - this.movedToBody && - document.body.contains(this.floatingContent) - ) { - if (!this.clickable) { - this.floatingContent.removeEventListener( - "mouseenter", - this.boundHandleMouseEnter, - ); - this.floatingContent.removeEventListener( - "mouseleave", - this.boundHandleMouseLeave, - ); - } - - document.body.removeChild(this.floatingContent); - this.movedToBody = false; - this.floatingContent = null; - } - }, - - updated() { - this.initElements(); - - if (this.floatingContent) { - this.floatingType = this.getFloatingType(); - this.setupFloatingContent(); - - if (!this.clickable) { - this.floatingContent.addEventListener( - "mouseenter", - this.boundHandleMouseEnter, - ); - this.floatingContent.addEventListener( - "mouseleave", - this.boundHandleMouseLeave, - ); - } - - this.updatePosition(); - } - }, - - initElements() { - this.floatingContent = - this.el.querySelector("[data-floating-content]") || - this.el.querySelector(".dropdown-content"); - - this.trigger = - this.el.querySelector("[data-floating-trigger]") || - this.el.querySelector(".dropdown-trigger"); - }, - - setupFloatingContent() { - if (this.floatingContent) { - this.originalParent = this.floatingContent.parentElement; - this.originalIndex = Array.from(this.originalParent.children).indexOf( - this.floatingContent, - ); - - document.body.appendChild(this.floatingContent); - this.movedToBody = true; - - this.setupAria(); - - this.floatingContent.setAttribute("hidden", ""); - } - }, - - cleanupFloatingContent() { - if (this.floatingContent) { - if (!this.clickable) { - this.floatingContent.removeEventListener( - "mouseenter", - this.boundHandleMouseEnter, - ); - this.floatingContent.removeEventListener( - "mouseleave", - this.boundHandleMouseLeave, - ); - } - - if (document.body.contains(this.floatingContent)) { - document.body.removeChild(this.floatingContent); - } - } - }, - - setupEventListeners() { - if (this.trigger) { - if (this.clickable) { - this.trigger.addEventListener("click", this.boundHandleClick); - } else { - this.trigger.addEventListener("mouseenter", this.boundHandleMouseEnter); - this.trigger.addEventListener("mouseleave", this.boundHandleMouseLeave); - this.trigger.addEventListener("focusin", this.boundHandleMouseEnter); - this.trigger.addEventListener("focusout", this.boundHandleMouseLeave); - this.floatingContent?.addEventListener( - "mouseenter", - this.boundHandleMouseEnter, - ); - this.floatingContent?.addEventListener( - "mouseleave", - this.boundHandleMouseLeave, - ); - } - - if (this.enableAria && this.floatingType === "dropdown") { - this.trigger.setAttribute("aria-haspopup", "menu"); - this.trigger.setAttribute("aria-expanded", "false"); - } - } - - this.updatePositionDebounced = this.debounce(this.boundUpdatePosition, 16); - - document.addEventListener("click", this.boundHandleOutsideClick); - document.addEventListener("keydown", this.boundHandleKeydown); - window.addEventListener("resize", this.updatePositionDebounced); - window.addEventListener("scroll", this.updatePositionDebounced, true); - }, - - getFloatingType() { - const role = this.floatingContent?.getAttribute("role"); - if (role === "tooltip") return "tooltip"; - if (role === "dialog" || role === "menu") return "dropdown"; - return "generic"; - }, - - setupAria() { - if (!this.trigger || !this.floatingContent) return; - - const timestamp = Date.now(); - const random = Math.random().toString(36).slice(2, 8); - const id = this.floatingContent.id || `floating-${timestamp}-${random}`; - this.floatingContent.id = id; - - if (this.floatingType === "tooltip") { - this.trigger.setAttribute("aria-describedby", id); - this.floatingContent.setAttribute("role", "tooltip"); - } - - if (this.floatingType === "dropdown") { - this.trigger.setAttribute("aria-controls", id); - this.trigger.setAttribute("aria-haspopup", "menu"); - this.floatingContent.setAttribute( - "aria-labelledby", - this.trigger.id || id, - ); - } - - if (this.floatingType === "popover") { - this.trigger.setAttribute("aria-haspopup", "dialog"); - this.trigger.setAttribute("aria-controls", id); - } - }, - - handleClick(e) { - e.stopPropagation(); - const allContents = document.querySelectorAll( - ".dropdown-content.show-dropdown", - ); - - allContents.forEach((content) => { - if (content && content !== this.floatingContent) { - this.setVisibility(content, false); - - const triggerId = content.getAttribute("aria-labelledby"); - if (triggerId) { - const triggerEl = document.getElementById(triggerId); - triggerEl?.setAttribute("aria-expanded", "false"); - } - } - }); - - if (this.floatingContent?.classList.contains("show-dropdown")) { - this.hide(); - } else { - this.show(); - } - }, - - handleMouseEnter() { - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - this.hideTimeout = null; - } - - if (this.showTimeout) { - clearTimeout(this.showTimeout); - this.showTimeout = null; - } - - if (this.showDelay > 0) { - this.showTimeout = setTimeout(() => { - this.show(); - this.showTimeout = null; - }, this.showDelay); - } else { - this.show(); - } - }, - - handleMouseLeave() { - if (this.showTimeout) { - clearTimeout(this.showTimeout); - this.showTimeout = null; - } - - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - this.hideTimeout = null; - } - - if (this.hideDelay > 0) { - this.hideTimeout = setTimeout(() => { - this.hide(); - this.hideTimeout = null; - }, this.hideDelay); - } else { - this.hide(); - } - }, - - handleOutsideClick(e) { - if ( - this.trigger && - !this.trigger.contains(e.target) && - (!this.floatingContent || !this.floatingContent.contains(e.target)) - ) { - this.hide(); - } - }, - - handleKeydown(e) { - if (!this.floatingContent?.classList.contains("show-dropdown")) return; - - const role = this.floatingContent.getAttribute("role"); - if (role !== "menu") return; - - const items = Array.from( - this.floatingContent.querySelectorAll( - '[role="menuitem"]:not([disabled]):not([hidden])', - ), - ); - if (!items.length) return; - - const currentIndex = items.indexOf(document.activeElement); - - if (e.key === "Escape") { - e.preventDefault(); - this.hide(); - this.trigger?.focus(); - } else if (e.key === "ArrowDown") { - e.preventDefault(); - const next = items[(currentIndex + 1) % items.length]; - next?.focus(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - const prev = items[(currentIndex - 1 + items.length) % items.length]; - prev?.focus(); - } else if ( - e.key === "Tab" && - !e.shiftKey && - currentIndex === items.length - 1 - ) { - e.preventDefault(); - this.hide(); - } else if (e.key === "Tab" && e.shiftKey && currentIndex === 0) { - e.preventDefault(); - this.hide(); - } - }, - - updatePosition() { - if (!this.trigger || !this.floatingContent) return; - - const originalDisplay = this.trigger.style.display; - const isSpan = this.trigger.tagName.toLowerCase() === "span"; - - if (isSpan && (!originalDisplay || originalDisplay === "inline")) { - this.trigger.style.display = "inline-block"; - } - - const rect = this.trigger.getBoundingClientRect(); - const content = this.floatingContent; - const gap = 5; - let pos = this.position; - - if (this.smartPositioning && content.offsetHeight) { - const { top, bottom, left, right } = rect; - const { innerHeight, innerWidth } = window; - const height = content.offsetHeight; - const width = content.offsetWidth; - - const spaceTop = top; - const spaceBottom = innerHeight - bottom; - const spaceLeft = left; - const spaceRight = innerWidth - right; - - if (pos === "bottom" && spaceBottom < height && spaceTop >= height) { - pos = "top"; - } else if (pos === "top" && spaceTop < height && spaceBottom >= height) { - pos = "bottom"; - } else if (pos === "left" && spaceLeft < width && spaceRight >= width) { - pos = "right"; - } else if (pos === "right" && spaceRight < width && spaceLeft >= width) { - pos = "left"; - } - } - - let top, left; - if (pos === "top") { - top = rect.top + window.scrollY - content.offsetHeight - gap; - left = (rect.left + rect.right) / 2 + window.scrollX; - content.style.transform = "translateX(-50%)"; - } else if (pos === "bottom") { - top = rect.bottom + window.scrollY + gap; - left = (rect.left + rect.right) / 2 + window.scrollX; - content.style.transform = "translateX(-50%)"; - } else if (pos === "left") { - top = - rect.top + window.scrollY + (rect.height - content.offsetHeight) / 2; - left = this.isRTL - ? rect.right + window.scrollX + gap - : rect.left + window.scrollX - content.offsetWidth - gap; - content.style.transform = "none"; - } else if (pos === "right") { - top = - rect.top + window.scrollY + (rect.height - content.offsetHeight) / 2; - left = this.isRTL - ? rect.left + window.scrollX - content.offsetWidth - gap - : rect.right + window.scrollX + gap; - content.style.transform = "none"; - } - - content.style.position = "absolute"; - content.style.top = `${top}px`; - content.style.left = `${left}px`; - - if (isSpan && originalDisplay !== "inline-block") { - this.trigger.style.display = originalDisplay || ""; - } - }, - - show() { - if (!this.floatingContent) return; - - const content = this.floatingContent; - - const transition = content.style.transition; - content.style.transition = "none"; - - this.setVisibility(content, true); - this.updatePosition(); - - const triggerWidth = this.trigger.offsetWidth; - if (!this.forcedWidth) { - const contentWidth = content.offsetWidth; - if (contentWidth < triggerWidth) { - this.forcedWidth = triggerWidth; - } - } - - content.style.width = this.forcedWidth ? `${this.forcedWidth}px` : "auto"; - - content.offsetHeight; - content.style.transition = transition; - - if (this.enableAria) { - content.removeAttribute("aria-hidden"); - if (this.trigger && this.floatingType === "dropdown") { - this.trigger.setAttribute("aria-expanded", "true"); - } - } - - if (this.floatingType === "dropdown") { - const firstItem = content.querySelector('[role="menuitem"]'); - firstItem?.focus(); - } - }, - - hide() { - if (!this.floatingContent) return; - - const isCurrentlyVisible = - this.floatingContent.classList.contains("show-dropdown"); - - this.setVisibility(this.floatingContent, false); - - if (this.enableAria && isCurrentlyVisible) { - setTimeout(() => { - if ( - this.floatingContent && - !this.floatingContent.classList.contains("show-dropdown") - ) { - this.floatingContent.setAttribute("aria-hidden", "true"); - } - }, 0); - } - }, - - cleanupAria() { - if (this.trigger) { - if (this.floatingType === "tooltip") { - this.trigger.removeAttribute("aria-describedby"); - } else if (this.floatingType === "dropdown") { - this.trigger.removeAttribute("aria-controls"); - this.trigger.removeAttribute("aria-haspopup"); - this.trigger.removeAttribute("aria-expanded"); - } else if (this.floatingType === "popover") { - this.trigger.removeAttribute("aria-haspopup"); - this.trigger.removeAttribute("aria-controls"); - } - } - }, - - destroyed() { - if (this.showTimeout) { - clearTimeout(this.showTimeout); - this.showTimeout = null; - } - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - this.hideTimeout = null; - } - - if (this.updatePositionDebounced) { - this.updatePositionDebounced.cancel?.(); - } - - document.removeEventListener("click", this.boundHandleOutsideClick); - document.removeEventListener("keydown", this.boundHandleKeydown); - window.removeEventListener( - "resize", - this.updatePositionDebounced || this.boundUpdatePosition, - ); - window.removeEventListener( - "scroll", - this.updatePositionDebounced || this.boundUpdatePosition, - true, - ); - - if (this.trigger && this.clickable) { - this.trigger.removeEventListener("click", this.boundHandleClick); - } - if (!this.clickable && this.trigger) { - this.trigger.removeEventListener( - "mouseenter", - this.boundHandleMouseEnter, - ); - this.trigger.removeEventListener( - "mouseleave", - this.boundHandleMouseLeave, - ); - this.trigger.removeEventListener("focusin", this.boundHandleMouseEnter); - this.trigger.removeEventListener("focusout", this.boundHandleMouseLeave); - } - - if (!this.clickable && this.floatingContent) { - this.floatingContent.removeEventListener( - "mouseenter", - this.boundHandleMouseEnter, - ); - this.floatingContent.removeEventListener( - "mouseleave", - this.boundHandleMouseLeave, - ); - } - - this.cleanupAria(); - - if ( - this.floatingContent && - this.movedToBody && - document.body.contains(this.floatingContent) - ) { - document.body.removeChild(this.floatingContent); - - if (this.originalParent) { - if ( - this.originalIndex >= 0 && - this.originalIndex < this.originalParent.children.length - ) { - this.originalParent.insertBefore( - this.floatingContent, - this.originalParent.children[this.originalIndex], - ); - } else { - this.originalParent.appendChild(this.floatingContent); - } - } - } - - this.floatingContent = null; - this.trigger = null; - this.originalParent = null; - }, -}; - -export default Floating; diff --git a/assets/vendor/galleryFilter.js b/assets/vendor/galleryFilter.js deleted file mode 100644 index 7f32550b..00000000 --- a/assets/vendor/galleryFilter.js +++ /dev/null @@ -1,193 +0,0 @@ -const GalleryFilter = { - mounted() { - // Extract configuration from data attributes and normalize to lowercase. - // If data-default-filter isn't provided, it defaults to "all". - this.defaultFilter = (this.el.dataset.defaultFilter || "all").toLowerCase(); - - // Cache DOM elements. - this.items = Array.from(this.el.querySelectorAll("[data-gallery-item]")); - this.filterButtons = Array.from( - this.el.querySelectorAll("[data-gallery-filter]"), - ); - - // Set up event listeners. - this._setupEventListeners(); - - // Apply initial filter without animation. - this.applyFilter(this.defaultFilter, null, true); - }, - - /** - * Set up click and keydown event listeners for filter buttons. - */ - _setupEventListeners() { - this.filterButtons.forEach((btn) => { - btn.addEventListener("click", () => { - if (btn.disabled) return; - // Normalize filter value to lowercase for consistent matching. - const category = btn.dataset.category.toLowerCase(); - if (category) this.applyFilter(category, btn); - }); - - btn.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - btn.click(); - } - }); - }); - }, - - /** - * Check if the item should be visible for the given category. - * If the selected category equals the defaultFilter (which acts as "all"), - * then all items are shown; otherwise, only items matching the category are shown. - * @param {HTMLElement} item - Gallery item element. - * @param {string} category - Category to check. - * @returns {boolean} True if the item should be visible. - */ - _isItemVisible(item, category) { - if (category === this.defaultFilter) return true; - // Here, we expect that the data attribute contains a comma-separated string - // representing an array of categories (e.g. "sky" or "sky,flowers"). - const itemCategories = item.dataset.category - .split(",") - .map((c) => c.trim().toLowerCase()); - return itemCategories.includes(category); - }, - - /** - * Capture current positions of items. - * @returns {Map} A map of items to their positions. - */ - _captureItemPositions() { - const positions = new Map(); - this.items.forEach((item) => { - positions.set(item, item.getBoundingClientRect()); - }); - return positions; - }, - - /** - * Update visibility of items based on the current filter. - * @param {string} category - Filter category. - */ - _updateItemVisibility(category) { - this.items.forEach((item) => { - const isVisible = this._isItemVisible(item, category); - item.style.display = isVisible ? "" : "none"; - if (isVisible) { - item.style.opacity = "0"; - item.style.transform = "scale(0.95)"; - } - }); - }, - - /** - * Show items immediately without animation. - */ - _showItemsImmediately() { - this.items.forEach((item) => { - if (item.style.display !== "none") { - item.style.opacity = "1"; - item.style.transform = "scale(1)"; - } - }); - }, - - /** - * Animate items from previous positions to their new positions. - * @param {Map} beforePositions - Map of items and their previous positions. - * @param {function} onComplete - Callback function when all animations are complete. - */ - _animateItems(beforePositions, onComplete) { - // Force browser reflow to ensure new positions are calculated. - void this.el.offsetWidth; - - const visibleItems = this.items.filter( - (item) => item.style.display !== "none", - ); - let animationsRemaining = visibleItems.length; - - if (animationsRemaining === 0) { - onComplete && onComplete(); - return; - } - - visibleItems.forEach((item) => { - const before = beforePositions.get(item); - const after = item.getBoundingClientRect(); - const dx = before.left - after.left; - const dy = before.top - after.top; - - if (dx || dy) { - item.style.transform = `translate(${dx}px, ${dy}px) scale(0.95)`; - } - - item.style.opacity = "0"; - - requestAnimationFrame(() => { - item.style.transition = "transform 300ms ease, opacity 300ms ease"; - item.style.transform = "translate(0, 0) scale(1)"; - item.style.opacity = "1"; - item.addEventListener( - "transitionend", - () => { - item.style.transition = ""; - animationsRemaining--; - if (animationsRemaining === 0) { - onComplete && onComplete(); - } - }, - { once: true }, - ); - }); - }); - }, - - /** - * Update active state of filter buttons. - * The active button remains disabled, while the others are enabled. - * @param {string} activeCategory - Current active category. - */ - _updateActiveFilterButton(activeCategory) { - this.filterButtons.forEach((btn) => { - const isActive = btn.dataset.category.toLowerCase() === activeCategory; - btn.setAttribute("aria-pressed", isActive); - if (isActive) { - btn.classList.add("active"); - btn.disabled = true; - } else { - btn.classList.remove("active"); - btn.disabled = false; - } - }); - }, - - /** - * Apply a filter to the gallery items. - * @param {string} category - Category to filter by. - * @param {HTMLElement|null} clickedButton - The button that was clicked (optional). - * @param {boolean} [skipAnimation=false] - Whether to skip the animation. - */ - applyFilter(category, clickedButton = null, skipAnimation = false) { - // Disable only the clicked button immediately. - if (clickedButton) { - clickedButton.disabled = true; - } - const beforePositions = this._captureItemPositions(); - this._updateItemVisibility(category); - - if (skipAnimation) { - this._showItemsImmediately(); - this._updateActiveFilterButton(category); - return; - } - - this._animateItems(beforePositions, () => { - this._updateActiveFilterButton(category); - }); - }, -}; - -export default GalleryFilter; diff --git a/assets/vendor/scrollArea.js b/assets/vendor/scrollArea.js deleted file mode 100644 index 39016d6f..00000000 --- a/assets/vendor/scrollArea.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * ScrollArea Hook for Phoenix LiveView - * - * This hook provides custom scroll area functionality for Phoenix LiveView components. - * It updates only the thumb positions based on the viewport dimensions and scroll position. - * The thumb sizes remain fixed. The scrollbar container is hidden only when the content size - * exactly matches the viewport size. - * - * @module ScrollArea - */ - -let ScrollArea = { - mounted() { - const { el } = this; - this.viewport = el.querySelector(".scroll-viewport"); - this.thumbY = el.querySelector(".thumb-y"); - this.thumbX = el.querySelector(".thumb-x"); - this.scrollbarY = el.querySelector(".scrollbar-y"); - this.scrollbarX = el.querySelector(".scrollbar-x"); - - // If viewport is not present, hide scrollbars and exit early - if (!this.viewport) { - if (this.scrollbarY) this.scrollbarY.style.display = "none"; - if (this.scrollbarX) this.scrollbarX.style.display = "none"; - return; - } - - // Cache arrow functions for performance and cleanup - this.updateThumbBound = () => this.updateThumb(); - this.handleResizeBound = () => this.updateThumb(); - - if (this.viewport) { - this.viewport.addEventListener("scroll", this.updateThumbBound); - } - window.addEventListener("resize", this.handleResizeBound); - - if (this.thumbY) { - this.onThumbYPointerDownBound = (e) => this.onThumbYPointerDown(e); - this.thumbY.addEventListener( - "pointerdown", - this.onThumbYPointerDownBound, - ); - } - if (this.thumbX) { - this.onThumbXPointerDownBound = (e) => this.onThumbXPointerDown(e); - this.thumbX.addEventListener( - "pointerdown", - this.onThumbXPointerDownBound, - ); - } - - this.resizeObserver = new ResizeObserver(() => { - this.updateThumb(); - }); - - this.resizeObserver.observe(this.viewport); - }, - - updateAxis({ contentSize, clientSize, scrollPos, thumb, scrollbar, axis }) { - if (contentSize <= clientSize) { - if (scrollbar) scrollbar.style.display = "none"; - return; - } - if (scrollbar) scrollbar.style.display = "block"; - if (thumb) { - const thumbSize = - axis === "vertical" - ? thumb.offsetHeight || 20 - : thumb.offsetWidth || 20; - const maxScroll = contentSize - clientSize; - const maxThumb = clientSize - thumbSize; - const pos = (scrollPos / maxScroll) * maxThumb; - thumb.style.transform = - axis === "vertical" ? `translateY(${pos}px)` : `translateX(${pos}px)`; - } - }, - - updateThumb() { - if (!this.viewport) return; - const { - scrollTop, - scrollHeight, - clientHeight, - scrollLeft, - scrollWidth, - clientWidth, - } = this.viewport; - - this.updateAxis({ - contentSize: scrollHeight, - clientSize: clientHeight, - scrollPos: scrollTop, - thumb: this.thumbY, - scrollbar: this.scrollbarY, - axis: "vertical", - }); - - this.updateAxis({ - contentSize: scrollWidth, - clientSize: clientWidth, - scrollPos: scrollLeft, - thumb: this.thumbX, - scrollbar: this.scrollbarX, - axis: "horizontal", - }); - }, - - onThumbYPointerDown(e) { - e.preventDefault(); - // Set dragging state to true for vertical thumb - this.isDraggingY = true; - if (this.scrollbarY) { - this.scrollbarY.style.visibility = "visible"; - } - this.startY = e.clientY; - this.startScrollTop = this.viewport.scrollTop; - this.boundThumbYPointerMove = (e) => this.onThumbYPointerMove(e); - this.boundThumbYPointerUp = () => this.onThumbYPointerUp(); - document.addEventListener("pointermove", this.boundThumbYPointerMove); - document.addEventListener("pointerup", this.boundThumbYPointerUp); - }, - - onThumbYPointerMove(e) { - e.preventDefault(); - const dy = e.clientY - this.startY; - const { scrollHeight, clientHeight } = this.viewport; - const thumbHeight = (this.thumbY && this.thumbY.offsetHeight) || 20; - const maxScroll = scrollHeight - clientHeight; - const maxThumb = clientHeight - thumbHeight; - const newScrollTop = Math.max( - 0, - Math.min(this.startScrollTop + dy * (maxScroll / maxThumb), maxScroll), - ); - this.viewport.scrollTop = newScrollTop; - }, - - onThumbYPointerUp() { - // Reset dragging state for vertical thumb - this.isDraggingY = false; - if (this.scrollbarY) { - this.scrollbarY.style.visibility = ""; - } - document.removeEventListener("pointermove", this.boundThumbYPointerMove); - document.removeEventListener("pointerup", this.boundThumbYPointerUp); - }, - - onThumbXPointerDown(e) { - e.preventDefault(); - // Set dragging state to true for horizontal thumb - this.isDraggingX = true; - if (this.scrollbarX) { - this.scrollbarX.style.visibility = "visible"; - } - this.startX = e.clientX; - this.startScrollLeft = this.viewport.scrollLeft; - this.boundThumbXPointerMove = (e) => this.onThumbXPointerMove(e); - this.boundThumbXPointerUp = () => this.onThumbXPointerUp(); - document.addEventListener("pointermove", this.boundThumbXPointerMove); - document.addEventListener("pointerup", this.boundThumbXPointerUp); - }, - - onThumbXPointerMove(e) { - e.preventDefault(); - const dx = e.clientX - this.startX; - const { scrollWidth, clientWidth } = this.viewport; - const thumbWidth = (this.thumbX && this.thumbX.offsetWidth) || 20; - const maxScroll = scrollWidth - clientWidth; - const maxThumb = clientWidth - thumbWidth; - const newScrollLeft = Math.max( - 0, - Math.min(this.startScrollLeft + dx * (maxScroll / maxThumb), maxScroll), - ); - this.viewport.scrollLeft = newScrollLeft; - }, - - onThumbXPointerUp() { - // Reset dragging state for horizontal thumb - this.isDraggingX = false; - if (this.scrollbarX) { - this.scrollbarX.style.visibility = ""; - } - document.removeEventListener("pointermove", this.boundThumbXPointerMove); - document.removeEventListener("pointerup", this.boundThumbXPointerUp); - }, - - destroyed() { - this.viewport?.removeEventListener("scroll", this.updateThumbBound); - window.removeEventListener("resize", this.handleResizeBound); - this.thumbY?.removeEventListener( - "pointerdown", - this.onThumbYPointerDownBound, - ); - this.thumbX?.removeEventListener( - "pointerdown", - this.onThumbXPointerDownBound, - ); - }, -}; - -export default ScrollArea; diff --git a/assets/vendor/sidebar.js b/assets/vendor/sidebar.js deleted file mode 100644 index 35a68b05..00000000 --- a/assets/vendor/sidebar.js +++ /dev/null @@ -1,136 +0,0 @@ -const Sidebar = { - defaults: { - expandedWidth: "250px", - collapsedWidth: "60px", - transitionDuration: "0.3s", - labelAnimationDelay: 10, - }, - - getElements(el) { - const sidebarSelector = el.getAttribute("data-sidebar-selector"); - if (!sidebarSelector) return null; - - if (!this.isValidSelector(sidebarSelector)) return null; - - const sidebar = document.querySelector(sidebarSelector); - if (!sidebar) return null; - - return { - sidebar, - labels: sidebar.querySelectorAll("[data-item-label]"), - }; - }, - - isValidSelector(selector) { - try { - document.querySelector(selector); - return true; - } catch (e) { - console.error("Invalid selector:", selector); - return false; - } - }, - - getConfig(sidebar) { - const expandedWidth = sidebar.getAttribute("data-expanded-width"); - const collapsedWidth = sidebar.getAttribute("data-collapsed-width"); - - return { - expandedWidth: - this.sanitizeCssValue(expandedWidth) || this.defaults.expandedWidth, - collapsedWidth: - this.sanitizeCssValue(collapsedWidth) || this.defaults.collapsedWidth, - isMinimized: sidebar.getAttribute("data-minimized") === "true", - }; - }, - - sanitizeCssValue(value) { - if (!value) return null; - - const validPattern = /^[0-9]+(px|em|rem|%|vh|vw)$/; - return validPattern.test(value) ? value : null; - }, - - expandSidebar(sidebar, labels, expandedWidth) { - sidebar.style.width = expandedWidth; - sidebar.setAttribute("data-minimized", "false"); - - this.animateLabels(labels, true); - }, - - collapseSidebar(sidebar, labels, collapsedWidth) { - sidebar.style.width = collapsedWidth; - sidebar.setAttribute("data-minimized", "true"); - - this.hideLabels(labels); - }, - - animateLabels(labels, show) { - if (show) { - labels.forEach((label) => { - label.classList.remove("hidden"); - label.classList.add("opacity-0"); - - setTimeout(() => { - label.classList.add( - "transition-opacity", - "duration-200", - "opacity-100", - ); - label.classList.remove("opacity-0"); - }, this.defaults.labelAnimationDelay); - }); - } - }, - - hideLabels(labels) { - labels.forEach((label) => { - label.classList.add("hidden"); - label.classList.remove( - "opacity-100", - "transition-opacity", - "duration-200", - ); - }); - }, - - Sidebar(elements, config) { - const { sidebar, labels } = elements; - const { expandedWidth, collapsedWidth, isMinimized } = config; - - sidebar.style.transition = `width ${this.defaults.transitionDuration} ease`; - - if (isMinimized) { - this.expandSidebar(sidebar, labels, expandedWidth); - } else { - this.collapseSidebar(sidebar, labels, collapsedWidth); - } - }, - - mounted() { - this.clickHandler = this.handleClick.bind(this); - this.el.addEventListener("click", this.clickHandler); - }, - - handleClick() { - const elements = this.getElements(this.el); - if (!elements) return; - - const config = this.getConfig(elements.sidebar); - this.Sidebar(elements, config); - - const icon = this.el.querySelector(".minimize-icon"); - if (icon) { - icon.classList.toggle("rotate-180"); - } - }, - - destroy() { - if (this.el && this.clickHandler) { - this.el.removeEventListener("click", this.clickHandler); - this.clickHandler = null; - } - }, -}; - -export default Sidebar; diff --git a/assets/vite.config.mjs b/assets/vite.config.mjs index 856ec23a..aa937c51 100644 --- a/assets/vite.config.mjs +++ b/assets/vite.config.mjs @@ -1,27 +1,31 @@ -import { defineConfig } from 'vite' -import vue from "@vitejs/plugin-vue"; -import liveVuePlugin from "live_vue/vitePlugin"; -import tailwindcss from "@tailwindcss/vite"; +import {defineConfig} from "vite" +import vue from "@vitejs/plugin-vue" +import liveVuePlugin from "live_vue/vitePlugin" +import tailwindcss from "@tailwindcss/vite" +import {fileURLToPath} from "node:url" -const phoenixPort = process.env.PORT ?? "4100"; +const phoenixHost = process.env.PHX_HOST ?? "localhost" +const phoenixPort = process.env.PORT ?? "4100" +const viteHost = process.env.VITE_HOST ?? (process.env.PHX_HOST ? "0.0.0.0" : "127.0.0.1") +const vitePort = Number(process.env.VITE_PORT ?? "5173") const allowedOrigins = [ `http://localhost:${phoenixPort}`, `http://127.0.0.1:${phoenixPort}`, -]; + `http://${phoenixHost}:${phoenixPort}`, +] export default defineConfig({ server: { - host: "127.0.0.1", - port: 5173, + host: viteHost, + port: vitePort, strictPort: true, - cors: { origin: allowedOrigins }, + cors: {origin: [...new Set(allowedOrigins)]}, }, optimizeDeps: { - // https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies include: ["live_vue", "phoenix", "phoenix_html", "phoenix_live_view"], }, - ssr: { noExternal: process.env.NODE_ENV === "production" ? true : undefined }, - build: { + ssr: {noExternal: process.env.NODE_ENV === "production" ? true : undefined}, + build: { manifest: false, ssrManifest: false, rollupOptions: { @@ -30,17 +34,11 @@ export default defineConfig({ outDir: "../priv/static", emptyOutDir: true, }, - // LV Colocated JS and Hooks - // https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.ColocatedJS.html#module-internals resolve: { alias: { - "@": ".", + "@": fileURLToPath(new URL(".", import.meta.url)), "phoenix-colocated": `${process.env.MIX_BUILD_PATH}/phoenix-colocated`, }, }, - plugins: [ - tailwindcss(), - vue(), - liveVuePlugin() - ] -}); + plugins: [tailwindcss(), vue(), liveVuePlugin()], +}) diff --git a/assets/vue/index.ts b/assets/vue/index.ts index 724ecc0d..34a5cea1 100644 --- a/assets/vue/index.ts +++ b/assets/vue/index.ts @@ -1,36 +1,31 @@ -import { h, type Component } from "vue" -import { createLiveVue, findComponent, type LiveHook, type ComponentMap } from "live_vue" +import {h, type Component} from "vue" +import { + createLiveVue, + findComponent, + type ComponentMap, + type LiveHook, +} from "live_vue" -// needed to make $live available in the Vue component declare module "vue" { interface ComponentCustomProperties { $live: LiveHook } } +export const liveVueComponents = {} satisfies ComponentMap + +export function resolveLiveVueComponent(name: string) { + return findComponent(liveVueComponents, name) +} + export default createLiveVue({ - // name will be passed as-is in v-component of the .vue HEEX component - resolve: name => { - // we're importing from ../../lib to allow collocating Vue files with LiveView files - // eager: true disables lazy loading - all these components will be part of the app.js bundle - // more: https://vite.dev/guide/features.html#glob-import - const components = { - ...import.meta.glob("./**/*.vue", { eager: true }), - ...import.meta.glob("../../lib/**/*.vue", { eager: true }), - } as ComponentMap + resolve: resolveLiveVueComponent, + setup: ({createApp, component, props, slots, plugin, el}) => { + const app = createApp({render: () => h(component as Component, props, slots)}) - // finds component by name or path suffix and gives a nice error message. - // `path/to/component/index.vue` can be found as `path/to/component` or simply `component` - // `path/to/Component.vue` can be found as `path/to/Component` or simply `Component` - return findComponent(components as ComponentMap, name) - }, - // it's a default implementation of creating and mounting vue app, you can easily extend it to add your own plugins, directives etc. - setup: ({ createApp, component, props, slots, plugin, el }) => { - const app = createApp({ render: () => h(component as Component, props, slots) }) app.use(plugin) - // add your own plugins here - // app.use(pinia) app.mount(el) + return app }, }) diff --git a/assets/vue/lib/utils.ts b/assets/vue/lib/utils.ts new file mode 100644 index 00000000..ba34d05c --- /dev/null +++ b/assets/vue/lib/utils.ts @@ -0,0 +1,6 @@ +import {clsx, type ClassValue} from "clsx" +import {twMerge} from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/components.json b/components.json new file mode 100644 index 00000000..4e29cc03 --- /dev/null +++ b/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "assets/css/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/vue/components", + "ui": "@/vue/components/ui", + "utils": "@/vue/lib/utils", + "lib": "@/vue/lib", + "composables": "@/vue/composables" + } +} diff --git a/lib/jido_code/workbench/inventory_surface.ex b/lib/jido_code/workbench/inventory_surface.ex index 031c4689..eaf29a4c 100644 --- a/lib/jido_code/workbench/inventory_surface.ex +++ b/lib/jido_code/workbench/inventory_surface.ex @@ -5,6 +5,7 @@ defmodule JidoCode.Workbench.InventorySurface do """ alias JidoCode.ManagedRepoRoutes + alias JidoCode.Workbench.{ Inventory, IssueTriageWorkflowKickoff, @@ -244,23 +245,23 @@ defmodule JidoCode.Workbench.InventorySurface do def memory_graph_hint(_project), do: nil @spec semantic_graph_hint_badge_class(map()) :: String.t() - def semantic_graph_hint_badge_class(%{state: :ready}), do: "badge badge-success badge-xs" - def semantic_graph_hint_badge_class(%{state: :blocked}), do: "badge badge-warning badge-xs" - def semantic_graph_hint_badge_class(%{state: :stale}), do: "badge badge-warning badge-xs" - def semantic_graph_hint_badge_class(%{state: :degraded}), do: "badge badge-warning badge-xs" - def semantic_graph_hint_badge_class(%{state: :failed}), do: "badge badge-error badge-xs" - def semantic_graph_hint_badge_class(%{state: :not_ready}), do: "badge badge-outline badge-xs" - def semantic_graph_hint_badge_class(_hint), do: "badge badge-outline badge-xs" + def semantic_graph_hint_badge_class(%{state: :ready}), do: status_pill_class(:success) + def semantic_graph_hint_badge_class(%{state: :blocked}), do: status_pill_class(:warning) + def semantic_graph_hint_badge_class(%{state: :stale}), do: status_pill_class(:warning) + def semantic_graph_hint_badge_class(%{state: :degraded}), do: status_pill_class(:warning) + def semantic_graph_hint_badge_class(%{state: :failed}), do: status_pill_class(:error) + def semantic_graph_hint_badge_class(%{state: :not_ready}), do: status_pill_class(:neutral) + def semantic_graph_hint_badge_class(_hint), do: status_pill_class(:neutral) @spec memory_graph_hint_badge_class(map()) :: String.t() - def memory_graph_hint_badge_class(%{state: :ready}), do: "badge badge-success badge-xs" - def memory_graph_hint_badge_class(%{state: :blocked}), do: "badge badge-warning badge-xs" - def memory_graph_hint_badge_class(%{state: :stale}), do: "badge badge-warning badge-xs" - def memory_graph_hint_badge_class(%{state: :invalidated}), do: "badge badge-warning badge-xs" - def memory_graph_hint_badge_class(%{state: :degraded}), do: "badge badge-warning badge-xs" - def memory_graph_hint_badge_class(%{state: :failed}), do: "badge badge-error badge-xs" - def memory_graph_hint_badge_class(%{state: :not_ready}), do: "badge badge-outline badge-xs" - def memory_graph_hint_badge_class(_hint), do: "badge badge-outline badge-xs" + def memory_graph_hint_badge_class(%{state: :ready}), do: status_pill_class(:success) + def memory_graph_hint_badge_class(%{state: :blocked}), do: status_pill_class(:warning) + def memory_graph_hint_badge_class(%{state: :stale}), do: status_pill_class(:warning) + def memory_graph_hint_badge_class(%{state: :invalidated}), do: status_pill_class(:warning) + def memory_graph_hint_badge_class(%{state: :degraded}), do: status_pill_class(:warning) + def memory_graph_hint_badge_class(%{state: :failed}), do: status_pill_class(:error) + def memory_graph_hint_badge_class(%{state: :not_ready}), do: status_pill_class(:neutral) + def memory_graph_hint_badge_class(_hint), do: status_pill_class(:neutral) @spec semantic_graph_hint_recovery_path(map(), String.t() | nil) :: String.t() | nil def semantic_graph_hint_recovery_path(project, detail_path) do @@ -300,13 +301,28 @@ defmodule JidoCode.Workbench.InventorySurface do end @spec run_outcome_status_badge_class(term()) :: String.t() - def run_outcome_status_badge_class("completed"), do: "badge badge-success badge-xs" - def run_outcome_status_badge_class("running"), do: "badge badge-info badge-xs" - def run_outcome_status_badge_class("failed"), do: "badge badge-error badge-xs" - def run_outcome_status_badge_class("cancelled"), do: "badge badge-warning badge-xs" - def run_outcome_status_badge_class("awaiting_approval"), do: "badge badge-warning badge-xs" - def run_outcome_status_badge_class("pending"), do: "badge badge-outline badge-xs" - def run_outcome_status_badge_class(_status), do: "badge badge-outline badge-xs" + def run_outcome_status_badge_class("completed"), do: status_pill_class(:success) + def run_outcome_status_badge_class("running"), do: status_pill_class(:info) + def run_outcome_status_badge_class("failed"), do: status_pill_class(:error) + def run_outcome_status_badge_class("cancelled"), do: status_pill_class(:warning) + def run_outcome_status_badge_class("awaiting_approval"), do: status_pill_class(:warning) + def run_outcome_status_badge_class("pending"), do: status_pill_class(:neutral) + def run_outcome_status_badge_class(_status), do: status_pill_class(:neutral) + + defp status_pill_class(tone) do + base = "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium" + + tone_class = + case tone do + :success -> "border-primary/30 bg-primary/10 text-primary" + :info -> "border-accent/30 bg-accent/10 text-accent-foreground" + :warning -> "border-destructive/30 bg-destructive/10 text-destructive" + :error -> "border-destructive/40 bg-destructive/15 text-destructive" + :neutral -> "border-border bg-transparent text-muted-foreground" + end + + base <> " " <> tone_class + end @spec run_outcome_status_label(term()) :: String.t() def run_outcome_status_label(status) do diff --git a/lib/jido_code_web.ex b/lib/jido_code_web.ex index d33ee2c3..809dea86 100644 --- a/lib/jido_code_web.ex +++ b/lib/jido_code_web.ex @@ -83,21 +83,14 @@ defmodule JidoCodeWeb do # Translation use Gettext, backend: JidoCodeWeb.Gettext - # LiveView remains the routed host shell; Vue mounts only inside HEEx surfaces. + # LiveView remains the routed host shell; Vue mounts only inside bounded HEEx surfaces. use LiveVue - # Generate helper functions for bounded Vue islands that live under the shared shell. - use LiveVue.Components, vue_root: ["./assets/vue", "./lib/jido_code_web"] + # Generate helper functions for bounded Vue islands that live under assets/vue. + use LiveVue.Components, vue_root: ["./assets/vue"] # HTML escaping functionality import Phoenix.HTML - # Core UI components - import JidoCodeWeb.CoreComponents - import JidoCodeWeb.ConversationSurfaceComponents - import JidoCodeWeb.LiveVueComponents - import JidoCodeWeb.MemorySurfaceComponents - import JidoCodeWeb.OperatorStateComponents - import JidoCodeWeb.OperatorShellComponents # Common modules used in templates alias Phoenix.LiveView.JS diff --git a/lib/jido_code_web/components/conversation_surface_components.ex b/lib/jido_code_web/components/conversation_surface_components.ex deleted file mode 100644 index 71bff7fe..00000000 --- a/lib/jido_code_web/components/conversation_surface_components.ex +++ /dev/null @@ -1,191 +0,0 @@ -defmodule JidoCodeWeb.ConversationSurfaceComponents do - # covers: architecture.conversation_orchestration.operator_surfaces_show_conversation_work_item_linkage - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.conversation_orchestration.degraded_mode_falls_back_to_persisted_state - @moduledoc false - - use Phoenix.Component - - attr :id, :string, default: nil - attr :status, :any, required: true - attr :class, :any, default: nil - - def conversation_status_badge(assigns) do - ~H""" - - {status_label(@status)} - - """ - end - - attr :id, :string, default: nil - attr :scope, :any, default: nil - attr :attachment_mode, :any, default: nil - attr :work_item_id, :string, default: nil - attr :historical, :boolean, default: false - attr :class, :any, default: nil - - def conversation_role_badge(assigns) do - ~H""" - - {role_label(assigns)} - - """ - end - - attr :id, :string, default: nil - attr :stream_mode, :any, required: true - attr :discontinuity_count, :integer, default: 0 - attr :class, :any, default: nil - - def conversation_stream_badge(assigns) do - ~H""" - - {stream_label(@stream_mode)} - 0} class="ml-1 text-[10px] opacity-80"> - +{@discontinuity_count} gaps - - - """ - end - - attr :id, :string, required: true - attr :sequence, :any, default: nil - attr :label, :string, required: true - attr :title, :string, required: true - attr :excerpt, :string, default: nil - attr :occurred_at, :string, default: nil - attr :event_name, :string, default: nil - attr :tone, :any, default: :neutral - - def conversation_event_row(assigns) do - ~H""" -
-
-
- - ##{@sequence} - - - {@label} - - - {@event_name} - -
- -
-

- {@title} -

-

- {@excerpt} -

-
- """ - end - - defp status_badge_class(status) do - case normalize_status(status) do - "active" -> "badge-success" - "paused" -> "badge-warning" - "completed" -> "badge-info" - "cancelled" -> "badge-error" - "failed" -> "badge-error" - "blocked" -> "badge-warning" - _other -> "badge-ghost" - end - end - - defp status_label(status), do: normalize_status(status) - - defp role_badge_class(%{historical: true}), do: "badge-info" - - defp role_badge_class(assigns) do - case role_key(assigns) do - :repo_intake -> "badge-neutral" - :governed -> "badge-primary" - _other -> "badge-ghost" - end - end - - defp role_label(%{historical: true}), do: "Historical lineage" - - defp role_label(assigns) do - case role_key(assigns) do - :repo_intake -> "Repo intake" - :governed -> "Governed conversation" - _other -> "Conversation" - end - end - - defp role_key(%{scope: scope, attachment_mode: attachment_mode, work_item_id: work_item_id}) do - normalized_scope = normalize_status(scope) - normalized_attachment = normalize_status(attachment_mode) - - cond do - present_string?(work_item_id) -> :governed - normalized_scope == "work_item_scoped" -> :governed - normalized_scope == "repo_scoped" and normalized_attachment == "pre_work" -> :repo_intake - normalized_attachment == "pre_work" -> :repo_intake - true -> :conversation - end - end - - defp stream_badge_class(stream_mode) do - case normalize_status(stream_mode) do - "live" -> "badge-success" - "degraded" -> "badge-warning" - "idle" -> "badge-ghost" - _other -> "badge-ghost" - end - end - - defp stream_label(stream_mode) do - case normalize_status(stream_mode) do - "live" -> "Live stream" - "degraded" -> "Snapshot only" - "idle" -> "Idle" - other -> other - end - end - - defp normalize_status(value) when is_binary(value) do - case String.trim(value) do - "" -> "unknown" - trimmed -> trimmed - end - end - - defp normalize_status(value) when is_atom(value), do: value |> Atom.to_string() |> normalize_status() - defp normalize_status(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_status(_value), do: "unknown" - - defp event_row_class(:error), do: "border-error/60 bg-error/10" - defp event_row_class(:warning), do: "border-warning/60 bg-warning/10" - defp event_row_class(:success), do: "border-success/60 bg-success/10" - defp event_row_class(:status), do: "border-info/50 bg-info/10" - defp event_row_class(:input), do: "border-primary/50 bg-primary/5" - defp event_row_class(:progress), do: "border-info/40 bg-info/5" - defp event_row_class(:tool), do: "border-base-300/70 bg-base-100" - defp event_row_class(:turn), do: "border-base-300/70 bg-base-200/20" - defp event_row_class(_tone), do: "border-base-300/70 bg-base-200/30" - - defp event_label_class(:error), do: "badge-error" - defp event_label_class(:warning), do: "badge-warning" - defp event_label_class(:success), do: "badge-success" - defp event_label_class(:status), do: "badge-info" - defp event_label_class(:input), do: "badge-primary" - defp event_label_class(:progress), do: "badge-info" - defp event_label_class(:tool), do: "badge-neutral" - defp event_label_class(:turn), do: "badge-ghost" - defp event_label_class(_tone), do: "badge-ghost" - - defp present_string?(value) when is_binary(value), do: String.trim(value) != "" - defp present_string?(_value), do: false -end diff --git a/lib/jido_code_web/components/core_components.ex b/lib/jido_code_web/components/core_components.ex deleted file mode 100644 index d8253901..00000000 --- a/lib/jido_code_web/components/core_components.ex +++ /dev/null @@ -1,447 +0,0 @@ -defmodule JidoCodeWeb.CoreComponents do - @moduledoc """ - Provides core UI components. - - At first glance, this module may seem daunting, but its goal is to provide - core building blocks for your application, such as tables, forms, and - inputs. The components consist mostly of markup and are well-documented - with doc strings and declarative assigns. You may customize and style - them in any way you want, based on your application growth and needs. - - The foundation for styling is Tailwind CSS, a utility-first CSS framework, - augmented with daisyUI, a Tailwind CSS plugin that provides UI components - and themes. Here are useful references: - - * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get - started and see the available components. - - * [Tailwind CSS](https://tailwindcss.com) - the foundational framework - we build on. You will use it for layout, sizing, flexbox, grid, and - spacing. - - * [Heroicons](https://heroicons.com) - see `icon/1` for usage. - - * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) - - the component system used by Phoenix. Some components, such as `<.link>` - and `<.form>`, are defined there. - - """ - use Phoenix.Component - use Gettext, backend: JidoCodeWeb.Gettext - - alias Phoenix.LiveView.JS - - @doc """ - Renders a button with navigation support. - - ## Examples - - <.button>Send! - <.button phx-click="go" variant="primary">Send! - <.button navigate={~p"/"}>Home - """ - attr :rest, :global, include: ~w(href navigate patch method download name value disabled) - attr :class, :any - attr :variant, :string, values: ~w(primary) - slot :inner_block, required: true - - def button(%{rest: rest} = assigns) do - variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} - - assigns = - assign_new(assigns, :class, fn -> - ["btn", Map.fetch!(variants, assigns[:variant])] - end) - - if rest[:href] || rest[:navigate] || rest[:patch] do - ~H""" - <.link class={@class} {@rest}> - {render_slot(@inner_block)} - - """ - else - ~H""" - - """ - end - end - - @doc """ - Renders an input with label and error messages. - - A `Phoenix.HTML.FormField` may be passed as argument, - which is used to retrieve the input name, id, and values. - Otherwise all attributes may be passed explicitly. - - ## Types - - This function accepts all HTML input types, considering that: - - * You may also set `type="select"` to render a ` - """ - end - - def input(%{type: "checkbox"} = assigns) do - assigns = - assign_new(assigns, :checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) - end) - - ~H""" -
- - <.error :for={msg <- @errors}>{msg} -
- """ - end - - def input(%{type: "select"} = assigns) do - ~H""" -
- - <.error :for={msg <- @errors}>{msg} -
- """ - end - - def input(%{type: "textarea"} = assigns) do - ~H""" -
- - <.error :for={msg <- @errors}>{msg} -
- """ - end - - # All other inputs text, datetime-local, url, password, etc. are handled here... - def input(assigns) do - ~H""" -
- - <.error :for={msg <- @errors}>{msg} -
- """ - end - - # Helper used by inputs to generate form errors - defp error(assigns) do - ~H""" -

- <.icon name="hero-exclamation-circle" class="size-5" /> - {render_slot(@inner_block)} -

- """ - end - - @doc """ - Renders a header with title. - """ - slot :inner_block, required: true - slot :subtitle - slot :actions - - def header(assigns) do - ~H""" -
-
-

- {render_slot(@inner_block)} -

-

- {render_slot(@subtitle)} -

-
-
{render_slot(@actions)}
-
- """ - end - - @doc """ - Renders a table with generic styling. - - ## Examples - - <.table id="users" rows={@users}> - <:col :let={user} label="id">{user.id} - <:col :let={user} label="username">{user.username} - - """ - attr :id, :string, required: true - attr :rows, :list, required: true - attr :row_id, :any, default: nil, doc: "the function for generating the row id" - attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" - - attr :row_item, :any, - default: &Function.identity/1, - doc: "the function for mapping each row before calling the :col and :action slots" - - slot :col, required: true do - attr :label, :string - end - - slot :action, doc: "the slot for showing user actions in the last table column" - - def table(assigns) do - assigns = - with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do - assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) - end - - ~H""" - - - - - - - - - - - - - -
{col[:label]} - {gettext("Actions")} -
- {render_slot(col, @row_item.(row))} - -
- <%= for action <- @action do %> - {render_slot(action, @row_item.(row))} - <% end %> -
-
- """ - end - - @doc """ - Renders a data list. - - ## Examples - - <.list> - <:item title="Title">{@post.title} - <:item title="Views">{@post.views} - - """ - slot :item, required: true do - attr :title, :string, required: true - end - - def list(assigns) do - ~H""" -
    -
  • -
    -
    {item.title}
    -
    {render_slot(item)}
    -
    -
  • -
- """ - end - - @doc """ - Renders a [Heroicon](https://heroicons.com). - - Heroicons come in three styles – outline, solid, and mini. - By default, the outline style is used, but solid and mini may - be applied by using the `-solid` and `-mini` suffix. - - You can customize the size and colors of the icons by setting - width, height, and background color classes. - - Icons are extracted from the `deps/heroicons` directory and bundled within - your compiled app.css by the plugin in `assets/vendor/heroicons.js`. - - ## Examples - - <.icon name="hero-x-mark" /> - <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> - """ - attr :name, :string, required: true - attr :class, :any, default: "size-4" - - def icon(%{name: "hero-" <> _} = assigns) do - ~H""" - - """ - end - - ## JS Commands - - def show(js \\ %JS{}, selector) do - JS.show(js, - to: selector, - time: 300, - transition: - {"transition-all ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", - "opacity-100 translate-y-0 sm:scale-100"} - ) - end - - def hide(js \\ %JS{}, selector) do - JS.hide(js, - to: selector, - time: 200, - transition: - {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", - "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} - ) - end - - @doc """ - Translates an error message using gettext. - """ - def translate_error({msg, opts}) do - # When using gettext, we typically pass the strings we want - # to translate as a static argument: - # - # # Translate the number of files with plural rules - # dngettext("errors", "1 file", "%{count} files", count) - # - # However the error messages in our forms and APIs are generated - # dynamically, so we need to translate them by calling Gettext - # with our gettext backend as first argument. Translations are - # available in the errors.po file (as we use the "errors" domain). - if count = opts[:count] do - Gettext.dngettext(JidoCodeWeb.Gettext, "errors", msg, msg, count, opts) - else - Gettext.dgettext(JidoCodeWeb.Gettext, "errors", msg, opts) - end - end - - @doc """ - Translates the errors for a field from a keyword list of errors. - """ - def translate_errors(errors, field) when is_list(errors) do - for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) - end -end diff --git a/lib/jido_code_web/components/layouts.ex b/lib/jido_code_web/components/layouts.ex index 63b40f89..3af1ad8c 100644 --- a/lib/jido_code_web/components/layouts.ex +++ b/lib/jido_code_web/components/layouts.ex @@ -1,313 +1,7 @@ defmodule JidoCodeWeb.Layouts do - @moduledoc """ - This module holds layouts and related functionality - used by your application. - """ + @moduledoc false + use JidoCodeWeb, :html - # Embed all files in layouts/* within this module. - # The default root.html.heex file contains the HTML - # skeleton of your application, namely HTML headers - # and other static content. embed_templates("layouts/*") - - @doc """ - Renders your app layout. - - This function is typically invoked from every template, - and it often contains your application menu, sidebar, - or similar. - - ## Examples - - -

Content

-
- - """ - attr(:flash, :map, required: true, doc: "the map of flash messages") - - attr(:current_scope, :map, - default: nil, - doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" - ) - - attr(:operator_navigation, :map, - default: nil, - doc: "shared signed-in operator navigation metadata" - ) - - slot(:inner_block, required: true) - - def app(assigns) do - ~H""" -
-
-
-
-
- <.link - navigate={~p"/"} - class="text-sm font-bold tracking-[0.12em] uppercase hover:text-primary" - > - Jido Code - - -
- - {@operator_navigation.route_badge} - - - {@operator_navigation.route_label} - -
-
- - <%!-- covers: baseline.surface.nav_trimmed --%> -

- Landing + Auth Only -

-
- -
- <.theme_toggle /> -
-
- -
- <.operator_navigation navigation={@operator_navigation} /> -
-
-
- -
-
- {render_slot(@inner_block)} -
-
- - <.live_toast_group - flash={@flash} - connected={assigns[:socket] != nil} - corner={:top_right} - toasts_sync={assigns[:toasts_sync] || []} - /> - """ - end - - attr(:navigation, :map, required: true) - - defp operator_navigation(assigns) do - ~H""" - - """ - end - - @doc """ - Renders a minimal onboarding layout with no navigation. - - Used for welcome/registration flows where a clean, centered - experience is preferred. - - ## Examples - - -

Welcome!

-
- - """ - attr(:flash, :map, required: true, doc: "the map of flash messages") - - slot(:inner_block, required: true) - - def onboarding(assigns) do - ~H""" -
-
-

Jido Code

-
- -
- {render_slot(@inner_block)} -
-
- - <.live_toast_group - flash={@flash} - connected={assigns[:socket] != nil} - corner={:top_right} - toasts_sync={assigns[:toasts_sync] || []} - /> - """ - end - - @doc """ - Provides dark vs light theme toggle based on themes defined in app.css. - - See in root.html.heex which applies the theme before page load. - """ - def theme_toggle(assigns) do - ~H""" -
-
- - - - - - -
- """ - end - - @doc """ - Wrapper component for LiveToast.toast_group that loads the module dynamically. - """ - attr(:flash, :map, required: true) - attr(:connected, :boolean, required: true) - attr(:corner, :atom, default: :bottom_right) - attr(:toasts_sync, :list, default: []) - - def live_toast_group(assigns) do - # Dynamically render LiveToast component - ~H""" -
- <.live_component - :if={@connected} - module={LiveToast.LiveComponent} - id="toast-group" - toasts_sync={@toasts_sync} - corner={@corner} - f={@flash} - kinds={[:info, :error]} - toast_class_fn={&JidoCodeWeb.Layouts.toast_class_fn/1} - /> -
-
-

{msg}

-
-
-
- """ - end - - @doc """ - Custom toast class function with improved color contrast for error toasts. - """ - def toast_class_fn(assigns) do - [ - # base classes - "group/toast z-100 pointer-events-auto relative w-full items-center justify-between origin-center overflow-hidden rounded-lg p-4 shadow-lg border col-start-1 col-end-1 row-start-1 row-end-2", - # start hidden if javascript is enabled - "[@media(scripting:enabled)]:opacity-0 [@media(scripting:enabled){[data-phx-main]_&}]:opacity-100", - # used to hide the disconnected flashes - if(assigns[:rest][:hidden] == true, do: "hidden", else: "flex"), - # override styles per severity - assigns[:kind] == :info && "bg-white text-black", - assigns[:kind] == :error && "bg-error text-error-content border-error" - ] - end end diff --git a/lib/jido_code_web/components/layouts/root.html.heex b/lib/jido_code_web/components/layouts/root.html.heex index a7d0d2ee..66900105 100644 --- a/lib/jido_code_web/components/layouts/root.html.heex +++ b/lib/jido_code_web/components/layouts/root.html.heex @@ -1,71 +1,20 @@ - {Application.get_env(:live_debugger, :live_debugger_tags)} - - <.live_title default="Jido Code" suffix=" · Phoenix Framework"> + <.live_title default="Jido.Code" suffix=" · Phoenix Framework"> {assigns[:page_title]} static_url(@conn, p) end} + to_url={fn path -> static_url(@conn, path) end} /> - - + {@inner_content} diff --git a/lib/jido_code_web/components/live_vue_components.ex b/lib/jido_code_web/components/live_vue_components.ex deleted file mode 100644 index d6ca0930..00000000 --- a/lib/jido_code_web/components/live_vue_components.ex +++ /dev/null @@ -1,231 +0,0 @@ -defmodule JidoCodeWeb.LiveVueComponents do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.live_vue_is_canonical_rich_component_bridge - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - @moduledoc """ - Product-owned helpers for mounting Vue-backed islands inside the LiveView shell. - - The goal is to keep `live_vue` usage consistent across the product: - - - LiveView owns routes, auth, sessions, and server-authored state - - Vue receives bounded props and optional top-level streams - - Vue emits map back into LiveView events through explicit bindings - """ - - use Phoenix.Component - - alias Phoenix.LiveView.JS - alias Phoenix.LiveView.LiveStream - alias JidoCodeWeb.FrontendAssets - - @component_name_pattern ~r/^[A-Z][A-Za-z0-9]*(?:\/[A-Z][A-Za-z0-9]*)*$/ - @reserved_prop_keys ~w( - __changed__ - __given__ - class - component - diff - events - id - inner_block - props - socket - ssr - streams - v-component - v-diff - v-on - v-socket - v-ssr - ) - - attr :component, :string, required: true - attr :socket, :any, required: true - attr :props, :map, default: %{} - attr :streams, :map, default: %{} - attr :events, :map, default: %{} - attr :id, :string, default: nil - attr :class, :any, default: nil - attr :ssr, :boolean, default: nil - attr :diff, :boolean, default: nil - attr :fallback_title, :string, default: nil - attr :fallback_detail, :string, default: nil - slot :inner_block - - def vue_surface(assigns) do - component = validate_component_name!(assigns.component) - props = validate_props!(assigns.props) - streams = validate_streams!(assigns.streams) - events = normalize_events!(assigns.events) - delivery = FrontendAssets.vue_surface_delivery(component) - - if delivery.mode == :fallback do - assigns = - assigns - |> assign(:delivery, delivery) - |> assign(:fallback_title, assigns.fallback_title || delivery.title) - |> assign(:fallback_detail, assigns.fallback_detail || delivery.detail) - |> assign(:fallback_id, fallback_id(assigns.id, component)) - - ~H""" -
-

{@fallback_title}

-

{@fallback_detail}

-

- Fallback mode reason: {humanize_reason(@delivery.reason)} -

- {render_slot(@inner_block)} -
- """ - else - vue_assigns = - assigns - |> Map.take([:__changed__, :inner_block]) - |> Map.put(:"v-component", component) - |> Map.put(:"v-socket", assigns.socket) - |> maybe_put(:id, assigns.id) - |> maybe_put(:class, assigns.class) - |> maybe_put(:"v-ssr", coalesce_ssr(assigns.ssr, delivery.ssr)) - |> maybe_put(:"v-diff", assigns.diff) - |> Map.merge(props) - |> Map.merge(streams) - - vue_assigns = - Enum.reduce(events, vue_assigns, fn {emit, handler}, acc -> - Map.put(acc, "v-on:#{emit}", handler) - end) - - LiveVue.vue(vue_assigns) - end - end - - defp maybe_put(assigns, _key, nil), do: assigns - defp maybe_put(assigns, key, value), do: Map.put(assigns, key, value) - - defp validate_component_name!(component) when is_binary(component) do - if Regex.match?(@component_name_pattern, component) do - component - else - raise ArgumentError, - "expected a PascalCase LiveVue component name or path, got: #{inspect(component)}" - end - end - - defp validate_component_name!(component) do - raise ArgumentError, - "expected a LiveVue component name as a string, got: #{inspect(component)}" - end - - defp validate_props!(props) when is_map(props) do - Enum.each(props, fn {key, _value} -> - normalized = normalize_key!(key, :props) - - cond do - normalized in @reserved_prop_keys -> - raise ArgumentError, "reserved prop key #{inspect(key)} cannot be passed through vue_surface/1" - - String.starts_with?(normalized, "v-on:") -> - raise ArgumentError, "event bindings belong in :events, not :props (got #{inspect(key)})" - - String.starts_with?(normalized, "v-") -> - raise ArgumentError, "reserved LiveVue key #{inspect(key)} cannot be passed through :props" - - true -> - :ok - end - end) - - props - end - - defp validate_props!(props) do - raise ArgumentError, "expected :props to be a map, got: #{inspect(props)}" - end - - defp validate_streams!(streams) when is_map(streams) do - Enum.each(streams, fn {key, value} -> - normalized = normalize_key!(key, :streams) - - if normalized in @reserved_prop_keys do - raise ArgumentError, "reserved stream key #{inspect(key)} cannot be passed through vue_surface/1" - end - - unless match?(%LiveStream{}, value) do - raise ArgumentError, - "expected stream #{inspect(key)} to be a Phoenix.LiveView.LiveStream, got: #{inspect(value)}" - end - end) - - streams - end - - defp validate_streams!(streams) do - raise ArgumentError, "expected :streams to be a map, got: #{inspect(streams)}" - end - - defp normalize_events!(events) when is_map(events) do - Map.new(events, fn {emit, handler} -> - emit = normalize_emit_name!(emit) - - normalized_handler = - case handler do - %JS{} = js -> - js - - event_name when is_binary(event_name) -> - JS.push(event_name) - - other -> - raise ArgumentError, - "expected event handler for #{inspect(emit)} to be a string or JS, got: #{inspect(other)}" - end - - {emit, normalized_handler} - end) - end - - defp normalize_events!(events) do - raise ArgumentError, "expected :events to be a map, got: #{inspect(events)}" - end - - defp normalize_key!(key, _context) when is_atom(key), do: Atom.to_string(key) - - defp normalize_key!(key, _context) when is_binary(key), do: key - - defp normalize_key!(key, context) do - raise ArgumentError, "expected #{context} keys to be atoms or strings, got: #{inspect(key)}" - end - - defp normalize_emit_name!(emit) do - emit = normalize_key!(emit, :events) - - if emit == "" or String.starts_with?(emit, "v-on:") do - raise ArgumentError, "expected a Vue emit name without the v-on: prefix, got: #{inspect(emit)}" - else - emit - end - end - - defp coalesce_ssr(explicit_ssr, nil), do: explicit_ssr - defp coalesce_ssr(_explicit_ssr, fallback_ssr), do: fallback_ssr - - defp fallback_id(nil, component), do: "vue-surface-fallback-#{String.replace(component, "/", "-")}" - defp fallback_id(id, _component), do: "#{id}-fallback" - - defp humanize_reason(reason) when is_atom(reason) do - reason - |> Atom.to_string() - |> String.replace("_", " ") - |> String.capitalize() - end - - defp humanize_reason(reason), do: to_string(reason) -end diff --git a/lib/jido_code_web/components/managed_repo_inventory_components.ex b/lib/jido_code_web/components/managed_repo_inventory_components.ex deleted file mode 100644 index f7bbe9c5..00000000 --- a/lib/jido_code_web/components/managed_repo_inventory_components.ex +++ /dev/null @@ -1,444 +0,0 @@ -defmodule JidoCodeWeb.ManagedRepoInventoryComponents do - @moduledoc false - - use JidoCodeWeb, :html - - alias JidoCode.Workbench.InventorySurface - - attr :row, :map, required: true - attr :dom_prefix, :string, required: true - attr :detail_path, :string, default: nil - - def managed_repo_hint_stack(assigns) do - ~H""" -
-
- - {InventorySurface.semantic_graph_hint(@row).label} - -

- {InventorySurface.semantic_graph_hint(@row).detail} -

- <.link - :if={InventorySurface.semantic_graph_hint_recovery_path(@row, @detail_path)} - id={"#{@dom_prefix}-semantic-hint-recovery-#{@row.id}"} - class="link link-primary text-[11px]" - href={InventorySurface.semantic_graph_hint_recovery_path(@row, @detail_path)} - > - {InventorySurface.semantic_graph_hint(@row).remediation} - -
- -
- - {InventorySurface.memory_graph_hint(@row).label} - -

- {InventorySurface.memory_graph_hint(@row).detail} -

- <.link - :if={InventorySurface.memory_graph_hint_recovery_path(@row, @detail_path)} - id={"#{@dom_prefix}-memory-hint-recovery-#{@row.id}"} - class="link link-primary text-[11px]" - href={InventorySurface.memory_graph_hint_recovery_path(@row, @detail_path)} - > - {InventorySurface.memory_graph_hint(@row).remediation} - -
- -
-
- <.conversation_role_badge - id={"#{@dom_prefix}-conversation-role-#{@row.id}"} - scope={InventorySurface.conversation_supervision_role_scope(@row)} - attachment_mode={InventorySurface.conversation_supervision_role_attachment_mode(@row)} - work_item_id={InventorySurface.conversation_supervision_role_work_item_id(@row)} - /> - <.conversation_status_badge - :if={InventorySurface.conversation_supervision_status(@row)} - id={"#{@dom_prefix}-conversation-status-#{@row.id}"} - status={InventorySurface.conversation_supervision_status(@row)} - /> - 0} - id={"#{@dom_prefix}-conversation-hint-badge-#{@row.id}"} - class="badge badge-sm badge-primary badge-outline font-medium" - > - {InventorySurface.conversation_supervision_active_count_label(@row)} - - 0} - id={"#{@dom_prefix}-conversation-clarification-#{@row.id}"} - class="badge badge-sm badge-warning badge-outline font-medium" - > - {InventorySurface.conversation_supervision_clarification_count_label(@row)} - -
-

- {InventorySurface.conversation_supervision_detail(@row)} -

-

- Latest governed work: {InventorySurface.conversation_supervision_work_item(@row).summary} ( {conversation_status_label( - InventorySurface.conversation_supervision_work_item(@row).status - )}) -

- <.link - :if={is_binary(@detail_path)} - id={"#{@dom_prefix}-conversation-link-#{@row.id}"} - class="link link-primary text-[11px]" - href={@detail_path} - > - {InventorySurface.conversation_supervision_action_label(@row)} - -
- -
- - Conversation state unavailable - -

- {InventorySurface.conversation_supervision_notice(@row).detail} -

- <.link - :if={is_binary(@detail_path)} - id={"#{@dom_prefix}-conversation-notice-link-#{@row.id}"} - class="link link-primary text-[11px]" - href={@detail_path} - > - Open repo detail - -
-
- """ - end - - attr :row, :map, required: true - attr :dom_prefix, :string, required: true - attr :detail_path, :string, default: nil - attr :kind, :atom, required: true - attr :recent_run_outcome, :map, default: nil - attr :triage_policy_state, :map, default: nil - attr :issue_triage_feedback, :map, default: nil - attr :fix_feedback, :map, default: nil - - def managed_repo_action_cluster(assigns) do - assigns = - assigns - |> assign(:link_target, action_link_target(assigns.row, assigns.detail_path, assigns.kind)) - |> assign(:github_target, github_target(assigns.row, assigns.kind)) - |> assign(:heading, if(assigns.kind == :issue, do: "Issues", else: "PRs")) - |> assign(:context_item_type, if(assigns.kind == :issue, do: "issue", else: "pull_request")) - - ~H""" - - """ - end - - attr :feedback, :map, default: nil - attr :dom_prefix, :string, required: true - - def inventory_workflow_feedback(assigns) do - ~H""" -
- <%= case @feedback.status do %> - <% :ok -> %> -

- {kickoff_success_status(@feedback)} -

-

- Run: {@feedback.run.run_id} -

- <.link - id={"#{@dom_prefix}-run-link"} - class="link link-primary text-[11px]" - href={@feedback.run.detail_path} - > - Open run detail - - <% :error -> %> -

- {kickoff_error_status(@feedback)} -

-

- Typed kickoff error: {@feedback.error.error_type} -

-

- {@feedback.error.detail} -

-

- {@feedback.error.remediation} -

- <% end %> -
- """ - end - - attr :outcome, :map, default: nil - attr :dom_prefix, :string, required: true - - def inventory_recent_run_outcome(assigns) do - ~H""" -
-

- Recent run outcome -

- <%= case @outcome do %> - <% nil -> %> -

- No recent run. -

- <% %{status: "unknown"} = outcome -> %> -

- Recent run status: unknown -

-

- Typed run outcome warning: {outcome.error_type} -

-

- {outcome.detail} -

-

- {outcome.guidance || "Refresh managed-repository inventory to resolve recent run status."} -

- <.link - :if={is_binary(outcome.detail_path)} - id={"#{@dom_prefix}-link"} - class="link link-primary text-[11px]" - href={outcome.detail_path} - > - Open run detail - - <% outcome -> %> -

- - {InventorySurface.run_outcome_status_label(outcome.status)} - -

-

- Run: {outcome.run_id} -

- <.link - id={"#{@dom_prefix}-link"} - class="link link-primary text-[11px]" - href={outcome.detail_path} - > - Open run detail - - <% end %> -
- """ - end - - attr :policy_state, :map, required: true - attr :dom_prefix, :string, required: true - - def inventory_issue_triage_policy_blocked_feedback(assigns) do - ~H""" -
- - Kick off issue triage workflow - -

- Policy state: {@policy_state.error_type} -

-

- {@policy_state.detail} -

-

- {@policy_state.remediation} -

-
- """ - end - - attr :link_id, :string, required: true - attr :disabled_id, :string, required: true - attr :reason_id, :string, required: true - attr :label, :string, required: true - attr :target, :string, default: nil - attr :disabled_reason, :string, required: true - attr :external, :boolean, default: false - - def inventory_row_link(assigns) do - ~H""" - <%= if is_binary(@target) do %> - <.link - id={@link_id} - class="link link-primary" - href={@target} - target={if @external, do: "_blank"} - rel={if @external, do: "noopener noreferrer"} - > - {@label} - - <% else %> - - {@label} - -

- Unavailable: {@disabled_reason} -

- <% end %> - """ - end - - defp action_link_target(row, detail_path, _kind) do - if is_binary(detail_path) do - detail_path - else - InventorySurface.project_detail_path(row) - end - end - - defp github_target(row, :issue), do: InventorySurface.issue_github_url(row) - defp github_target(row, :pull_request), do: InventorySurface.pull_request_github_url(row) - - defp kickoff_success_status(feedback) when is_map(feedback) do - case Map.get(feedback, :confirmation_state) do - :confirmed_after_interruption -> - "Kickoff confirmed after interruption: run was created." - - _other -> - "Kickoff confirmed: run was created." - end - end - - defp kickoff_error_status(feedback) when is_map(feedback) do - case Map.get(feedback, :confirmation_state) do - :not_created_after_interruption -> - "Kickoff failed after interruption: run was not created." - - _other -> - "Kickoff failed: review typed error details." - end - end - - defp conversation_status_label(status) when is_binary(status), do: status - defp conversation_status_label(status) when is_atom(status), do: Atom.to_string(status) - defp conversation_status_label(_status), do: "unknown" -end diff --git a/lib/jido_code_web/components/memory_surface_components.ex b/lib/jido_code_web/components/memory_surface_components.ex deleted file mode 100644 index 63d32579..00000000 --- a/lib/jido_code_web/components/memory_surface_components.ex +++ /dev/null @@ -1,347 +0,0 @@ -defmodule JidoCodeWeb.MemorySurfaceComponents do - # covers: architecture.memory_graph_product_adoption.memory_and_provenance_views_can_cross_link_to_source_code - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.memory_graph_product_adoption.operator_surfaces_do_not_expose_raw_memory_graph_internals - # covers: architecture.memory_graph_workflow_and_operator_expansion.cross_graph_navigation_connects_memory_code_and_governed_history - @moduledoc false - - use Phoenix.Component - - import JidoCodeWeb.OperatorStateComponents - - attr :id, :string, required: true - attr :title, :string, required: true - attr :state, :map, required: true - attr :kind, :atom, default: :warning - attr :recovery, :map, default: nil - attr :recover_event, :any, default: nil - attr :recover_id, :string, default: nil - - def memory_status_notice(assigns) do - assigns = - assign_new(assigns, :recover_id, fn -> - "#{assigns.id}-recover" - end) - - ~H""" - <.operator_state_notice id={@id} title={@title} state={@state} kind={@kind}> - <:actions> - - - - """ - end - - attr :dom_prefix, :string, required: true - attr :item, :map, required: true - attr :include_governed, :boolean, default: true - attr :conversation_label, :string, default: "Canonical conversation" - attr :governed_label, :string, default: "Governed context" - attr :source_label, :string, default: "Source code" - attr :related_label, :string, default: "Related memory" - - def memory_link_groups(assigns) do - ~H""" -
- <.memory_link_group - :if={conversation_links(@item) != []} - dom_prefix={"#{@dom_prefix}-conversation"} - label={@conversation_label} - links={conversation_links(@item)} - /> - <.memory_link_group - :if={@include_governed and navigation_links(@item, :governed_records) != []} - dom_prefix={"#{@dom_prefix}-governed"} - label={@governed_label} - links={navigation_links(@item, :governed_records)} - /> - <.memory_link_group - :if={navigation_links(@item, :source_code) != []} - dom_prefix={"#{@dom_prefix}-source"} - label={@source_label} - links={navigation_links(@item, :source_code)} - /> - <.memory_link_group - :if={navigation_links(@item, :related_memories) != []} - dom_prefix={"#{@dom_prefix}-related"} - label={@related_label} - links={navigation_links(@item, :related_memories)} - /> -
- """ - end - - attr :dom_prefix, :string, required: true - attr :item, :map, required: true - - def conversation_origin_card(assigns) do - ~H""" -
-
-

- {conversation_origin_summary(@item)} -

-

- {conversation_origin_preview(@item)} -

-

- {Enum.join(conversation_origin_metadata(@item), " | ")} -

-

- Code anchor: {conversation_anchor(@item)} -

-
- - <.memory_link_groups dom_prefix={@dom_prefix} item={@item} /> -
- """ - end - - attr :dom_prefix, :string, required: true - attr :label, :string, required: true - attr :links, :list, required: true - - def memory_link_group(assigns) do - ~H""" -
-

- {@label} -

-
- <%= for {link, index} <- Enum.with_index(@links, 1) do %> - <%= if is_binary(link.route) do %> - <.link - id={"#{@dom_prefix}-link-#{index}"} - href={link.route} - class="link link-primary text-xs" - > - {link.label} - - <% else %> - - {link.label} - - <% end %> - <% end %> -
-
- """ - end - - defp navigation_links(item, key) when is_map(item) do - item - |> map_get(:navigation, "navigation", %{}) - |> map_get(key, Atom.to_string(key), []) - |> Enum.map(&normalize_link/1) - |> Enum.filter(&(is_binary(Map.get(&1, :label)) and String.trim(Map.get(&1, :label)) != "")) - end - - defp navigation_links(_item, _key), do: [] - - defp conversation_links(item) when is_map(item) do - route = - item - |> map_get(:conversation_route, "conversation_route") - |> normalize_optional_string() - - label = - item - |> map_get(:conversation_route_label, "conversation_route_label") - |> normalize_optional_string() - - if is_binary(route) do - [ - %{ - kind: :conversation, - id: nil, - label: label || "Open canonical conversation", - route: route - } - ] - else - [] - end - end - - defp conversation_links(_item), do: [] - - defp normalize_link(link) when is_map(link) do - %{ - kind: map_get(link, :kind, "kind"), - id: map_get(link, :id, "id"), - label: map_get(link, :label, "label") || fallback_label(link), - route: map_get(link, :route, "route") - } - end - - defp normalize_link(_link), do: %{kind: nil, id: nil, label: nil, route: nil} - - defp fallback_label(link) do - kind = - link - |> map_get(:kind, "kind") - |> normalize_optional_string() - - id = - link - |> map_get(:id, "id") - |> normalize_optional_string() - - case {kind, id} do - {nil, nil} -> nil - {kind, nil} -> kind - {nil, id} -> id - {kind, id} -> "#{kind} #{id}" - end - end - - defp conversation_origin_summary(item) when is_map(item) do - item - |> map_get(:origin_summary, "origin_summary") - |> normalize_optional_string() || - item - |> map_get(:label, "label") - |> normalize_optional_string() || - "Conversation origin remains available as bounded recall." - end - - defp conversation_origin_summary(_item), do: "Conversation origin remains available as bounded recall." - - defp conversation_origin_preview(item) when is_map(item) do - item - |> map_get(:content_preview, "content_preview") - |> normalize_optional_string() || - item - |> map_get(:content, "content") - |> normalize_optional_string() - end - - defp conversation_origin_preview(_item), do: nil - - defp conversation_origin_metadata(item) when is_map(item) do - [] - |> maybe_append_segment(latest_event_segment(item)) - |> maybe_append_segment(conversation_id_segment(item)) - |> maybe_append_segment(scope_segment(item)) - |> maybe_append_segment(status_segment(item)) - end - - defp conversation_origin_metadata(_item), do: [] - - defp latest_event_segment(item) do - item - |> map_get(:latest_event, "latest_event") - |> humanize_segment("Latest event") - end - - defp conversation_id_segment(item) do - item - |> conversation_context_value(:conversation_id, "conversation_id") - |> then(fn - nil -> nil - conversation_id -> "Conversation: #{conversation_id}" - end) - end - - defp scope_segment(item) do - item - |> conversation_context_value(:scope, "scope") - |> humanize_segment("Scope") - end - - defp status_segment(item) do - item - |> conversation_context_value(:status, "status") - |> humanize_segment("Status") - end - - defp conversation_anchor(item) when is_map(item) do - item - |> map_get(:module_name, "module_name") - |> normalize_optional_string() || - item - |> map_get(:function_name, "function_name") - |> normalize_optional_string() - end - - defp conversation_anchor(_item), do: nil - - defp conversation_context_value(item, atom_key, string_key) when is_map(item) do - item - |> map_get(:conversation_context, "conversation_context", %{}) - |> map_get(atom_key, string_key) - |> normalize_optional_string() - end - - defp maybe_append_segment(segments, nil), do: segments - defp maybe_append_segment(segments, segment), do: segments ++ [segment] - - defp humanize_segment(value, label) when is_binary(label) do - case normalize_optional_string(value) do - nil -> - nil - - normalized -> - humanized = - normalized - |> String.replace("_", " ") - |> String.replace("-", " ") - |> String.capitalize() - - "#{label}: #{humanized}" - end - end - - defp recovery_available?(%{available?: true}), do: true - defp recovery_available?(_recovery), do: false - - defp recovery_label(%{label: label}) when is_binary(label), do: label - defp recovery_label(_recovery), do: "Recover memory graph" - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized -> normalized - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(_value), do: nil -end diff --git a/lib/jido_code_web/components/operator_shell_components.ex b/lib/jido_code_web/components/operator_shell_components.ex deleted file mode 100644 index 150586e9..00000000 --- a/lib/jido_code_web/components/operator_shell_components.ex +++ /dev/null @@ -1,403 +0,0 @@ -defmodule JidoCodeWeb.OperatorShellComponents do - @moduledoc false - - use Phoenix.Component - - import JidoCodeWeb.CoreComponents - - attr :id, :string, required: true - attr :breadcrumbs, :list, default: [] - attr :parent_subjects, :list, default: [] - attr :child_subjects, :list, default: [] - attr :child_nav_id, :string, required: true - attr :child_nav_label, :string, required: true - attr :child_nav_heading, :string, required: true - attr :child_nav_summary, :string, default: nil - attr :sidebar_id, :string, default: nil - attr :content_id, :string, default: nil - attr :class, :any, default: nil - slot :inner_block, required: true - - def subject_tree_shell(assigns) do - ~H""" -
- <.breadcrumb_lane id={"#{@id}-breadcrumbs"} breadcrumbs={@breadcrumbs} /> - <.parent_subject_rail - :if={@parent_subjects != []} - id={"#{@id}-parent-subjects"} - subjects={@parent_subjects} - /> - -
- - -
- {render_slot(@inner_block)} -
-
-
- """ - end - - attr :id, :string, required: true - attr :breadcrumbs, :list, default: [] - attr :pane, :map, required: true - attr :class, :any, default: nil - attr :pane_class, :any, default: nil - slot :inner_block, required: true - slot :footer_actions - - def single_pane_shell(assigns) do - ~H""" -
- <.breadcrumb_lane id={"#{@id}-breadcrumbs"} breadcrumbs={@breadcrumbs} /> - - <.subject_pane pane={@pane} class={@pane_class}> - {render_slot(@inner_block)} - - <:footer_actions> - {render_slot(@footer_actions)} - - -
- """ - end - - attr :id, :string, required: true - attr :breadcrumbs, :list, default: [] - - def breadcrumb_lane(assigns) do - ~H""" - - """ - end - - attr :breadcrumb, :map, required: true - - defp breadcrumb_item(assigns) do - ~H""" - - {@breadcrumb.label} - - - <.link - :if={!@breadcrumb.current? and is_binary(@breadcrumb.navigate)} - id={@breadcrumb.id} - navigate={@breadcrumb.navigate} - class="link link-hover font-medium" - > - {@breadcrumb.label} - - - <.link - :if={!@breadcrumb.current? and !is_binary(@breadcrumb.navigate) and is_binary(@breadcrumb.patch)} - id={@breadcrumb.id} - patch={@breadcrumb.patch} - class="link link-hover font-medium" - > - {@breadcrumb.label} - - - - {@breadcrumb.label} - - """ - end - - attr :id, :string, required: true - attr :subjects, :list, default: [] - - def parent_subject_rail(assigns) do - ~H""" - - """ - end - - attr :rail_id, :string, required: true - attr :subject, :map, required: true - - defp subject_chip(assigns) do - ~H""" - <.link - :if={is_binary(@subject.patch)} - id={"#{@rail_id}-#{@subject.id}"} - patch={@subject.patch} - aria-current={if(@subject.selected?, do: "page", else: nil)} - class={[ - "inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2", - if(@subject.selected?, - do: "border-primary/60 bg-primary/8 text-base-content shadow-sm", - else: "border-base-300/70 bg-base-200/20 text-base-content/80 hover:border-base-300 hover:bg-base-100" - ) - ]} - > - <.subject_chip_content subject={@subject} /> - - - <.link - :if={!is_binary(@subject.patch) and is_binary(@subject.navigate)} - id={"#{@rail_id}-#{@subject.id}"} - navigate={@subject.navigate} - aria-current={if(@subject.selected?, do: "page", else: nil)} - class={[ - "inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2", - if(@subject.selected?, - do: "border-primary/60 bg-primary/8 text-base-content shadow-sm", - else: "border-base-300/70 bg-base-200/20 text-base-content/80 hover:border-base-300 hover:bg-base-100" - ) - ]} - > - <.subject_chip_content subject={@subject} /> - - - - <.subject_chip_content subject={@subject} /> - - """ - end - - attr :subject, :map, required: true - - defp subject_chip_content(assigns) do - ~H""" - - {@subject.label} - <.subject_description_bubble description={@subject.description} /> - - """ - end - - attr :description, :string, default: nil - - defp subject_description_bubble(assigns) do - ~H""" - - <.icon name="hero-information-circle-mini" class="size-3.5" /> - - """ - end - - attr :id, :string, required: true - attr :label, :string, required: true - attr :heading, :string, required: true - attr :summary, :string, default: nil - attr :subjects, :list, default: [] - - def child_subject_sidebar(assigns) do - ~H""" -
-
-

- {@heading} -

-

- {@summary} -

-
- - -
- """ - end - - attr :nav_id, :string, required: true - attr :subject, :map, required: true - - defp subject_link(assigns) do - ~H""" - <.link - :if={is_binary(@subject.patch)} - id={"#{@nav_id}-#{@subject.id}"} - patch={@subject.patch} - aria-current={if(@subject.selected?, do: "page", else: nil)} - aria-controls={@subject.pane_id} - aria-describedby={subject_link_describedby(@nav_id, @subject)} - data-pane-id={@subject.pane_id} - class={[ - "rounded-lg border px-3 py-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2", - if(@subject.selected?, - do: "border-primary/60 bg-primary/8 text-base-content shadow-sm", - else: "border-base-300/70 bg-base-100/85 text-base-content/80 hover:border-base-300 hover:bg-base-100" - ) - ]} - > - <.subject_link_content nav_id={@nav_id} subject={@subject} /> - - - <.link - :if={!is_binary(@subject.patch) and is_binary(@subject.navigate)} - id={"#{@nav_id}-#{@subject.id}"} - navigate={@subject.navigate} - aria-current={if(@subject.selected?, do: "page", else: nil)} - aria-controls={@subject.pane_id} - aria-describedby={subject_link_describedby(@nav_id, @subject)} - data-pane-id={@subject.pane_id} - class={[ - "rounded-lg border px-3 py-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2", - if(@subject.selected?, - do: "border-primary/60 bg-primary/8 text-base-content shadow-sm", - else: "border-base-300/70 bg-base-100/85 text-base-content/80 hover:border-base-300 hover:bg-base-100" - ) - ]} - > - <.subject_link_content nav_id={@nav_id} subject={@subject} /> - - """ - end - - attr :nav_id, :string, required: true - attr :subject, :map, required: true - - defp subject_link_content(assigns) do - ~H""" -
-
-

{@subject.label}

-

- {@subject.summary} -

-
- - {@subject.badge.label} - -
- """ - end - - attr :pane, :map, required: true - attr :class, :any, default: nil - slot :inner_block, required: true - slot :footer_actions - - def subject_pane(assigns) do - ~H""" -
-
-

{@pane.title}

-

- {@pane.summary} -

-
- -
- {render_slot(@inner_block)} -
- -
- {render_slot(@footer_actions)} -
-
- """ - end - - defp subject_link_describedby(nav_id, subject) do - [subject_summary_id(nav_id, subject), subject_badge_id(nav_id, subject)] - |> Enum.reject(&is_nil/1) - |> case do - [] -> nil - ids -> Enum.join(ids, " ") - end - end - - defp subject_summary_id(nav_id, %{summary: summary, id: id}) when is_binary(summary), - do: "#{nav_id}-#{id}-summary" - - defp subject_summary_id(_nav_id, _subject), do: nil - - defp subject_badge_id(nav_id, %{badge: badge, id: id}) when is_map(badge), - do: "#{nav_id}-#{id}-badge" - - defp subject_badge_id(_nav_id, _subject), do: nil - - defp subject_badge_class(%{tone: :warning}), - do: "border-warning/50 bg-warning/10 text-warning" - - defp subject_badge_class(%{tone: :neutral}), - do: "border-base-300/70 bg-base-200/30 text-base-content/80" - - defp subject_badge_class(_badge), - do: "border-base-300/70 bg-base-200/30 text-base-content/80" -end diff --git a/lib/jido_code_web/components/operator_state_components.ex b/lib/jido_code_web/components/operator_state_components.ex deleted file mode 100644 index e16112c0..00000000 --- a/lib/jido_code_web/components/operator_state_components.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule JidoCodeWeb.OperatorStateComponents do - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - @moduledoc false - - use Phoenix.Component - - attr :id, :string, required: true - attr :title, :string, required: true - attr :state, :map, required: true - attr :kind, :atom, default: :warning - attr :compact, :boolean, default: false - attr :dom_prefix, :string, default: nil - attr :class, :any, default: nil - slot :actions - slot :inner_block - - def operator_state_notice(assigns) do - ~H""" -
-

{@title}

-

- {typed_prefix(@kind)}: {typed_state_value(@state)} -

-

- {state_detail(@state)} -

-

- {state_remediation(@state)} -

- {render_slot(@inner_block)} -
- {render_slot(@actions)} -
-
- """ - end - - defp state_classes(:error), do: "border-error/60 bg-error/10" - defp state_classes(:info), do: "border-info/60 bg-info/10" - defp state_classes(_kind), do: "border-warning/60 bg-warning/10" - - defp typed_prefix(:error), do: "Typed error" - defp typed_prefix(:info), do: "Typed info" - defp typed_prefix(:notice), do: "Typed notice" - defp typed_prefix(_kind), do: "Typed warning" - - defp typed_state_value(state), do: Map.get(state, :error_type) || Map.get(state, "error_type") - defp state_detail(state), do: Map.get(state, :detail) || Map.get(state, "detail") - defp state_remediation(state), do: Map.get(state, :remediation) || Map.get(state, "remediation") - defp state_dom_prefix(nil, id), do: id - defp state_dom_prefix(prefix, _id), do: prefix -end diff --git a/lib/jido_code_web/controllers/page_html.ex b/lib/jido_code_web/controllers/page_html.ex index ffb71dba..623232ad 100644 --- a/lib/jido_code_web/controllers/page_html.ex +++ b/lib/jido_code_web/controllers/page_html.ex @@ -1,10 +1,5 @@ defmodule JidoCodeWeb.PageHTML do - @moduledoc """ - This module contains pages rendered by PageController. + @moduledoc false - See the `page_html` directory for all templates available. - """ use JidoCodeWeb, :html - - embed_templates "page_html/*" end diff --git a/lib/jido_code_web/controllers/page_html/home.html.heex b/lib/jido_code_web/controllers/page_html/home.html.heex deleted file mode 100644 index 75e66351..00000000 --- a/lib/jido_code_web/controllers/page_html/home.html.heex +++ /dev/null @@ -1,15 +0,0 @@ -
-
-
-

Jido Code

-

AI Agent Platform

-
-
-
- Sign In -
OR
- Create Account -
-
-
-
diff --git a/lib/jido_code_web/live/DashboardRunSummaryWidget.vue b/lib/jido_code_web/live/DashboardRunSummaryWidget.vue deleted file mode 100644 index 85f748cf..00000000 --- a/lib/jido_code_web/live/DashboardRunSummaryWidget.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/DashboardRuntimePostureWidget.vue b/lib/jido_code_web/live/DashboardRuntimePostureWidget.vue deleted file mode 100644 index 1030c84e..00000000 --- a/lib/jido_code_web/live/DashboardRuntimePostureWidget.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/ProjectDetailOverviewWidget.vue b/lib/jido_code_web/live/ProjectDetailOverviewWidget.vue deleted file mode 100644 index d50b32bd..00000000 --- a/lib/jido_code_web/live/ProjectDetailOverviewWidget.vue +++ /dev/null @@ -1,123 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/ProjectDetailSemanticExplorerWidget.vue b/lib/jido_code_web/live/ProjectDetailSemanticExplorerWidget.vue deleted file mode 100644 index d55f8d53..00000000 --- a/lib/jido_code_web/live/ProjectDetailSemanticExplorerWidget.vue +++ /dev/null @@ -1,221 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/RunGovernanceOverviewWidget.vue b/lib/jido_code_web/live/RunGovernanceOverviewWidget.vue deleted file mode 100644 index 75ef1ff9..00000000 --- a/lib/jido_code_web/live/RunGovernanceOverviewWidget.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/SettingsOverviewWidget.vue b/lib/jido_code_web/live/SettingsOverviewWidget.vue deleted file mode 100644 index e9440495..00000000 --- a/lib/jido_code_web/live/SettingsOverviewWidget.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/SetupGitHubRepositorySelectorWidget.vue b/lib/jido_code_web/live/SetupGitHubRepositorySelectorWidget.vue deleted file mode 100644 index 68759e3f..00000000 --- a/lib/jido_code_web/live/SetupGitHubRepositorySelectorWidget.vue +++ /dev/null @@ -1,512 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/SetupRuntimeDefaultsWidget.vue b/lib/jido_code_web/live/SetupRuntimeDefaultsWidget.vue deleted file mode 100644 index 5692edb1..00000000 --- a/lib/jido_code_web/live/SetupRuntimeDefaultsWidget.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/SetupStartPathSelectorWidget.vue b/lib/jido_code_web/live/SetupStartPathSelectorWidget.vue deleted file mode 100644 index 5dbad4ff..00000000 --- a/lib/jido_code_web/live/SetupStartPathSelectorWidget.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/WorkbenchSummaryWidget.vue b/lib/jido_code_web/live/WorkbenchSummaryWidget.vue deleted file mode 100644 index 59c0b405..00000000 --- a/lib/jido_code_web/live/WorkbenchSummaryWidget.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - diff --git a/lib/jido_code_web/live/agents_live.ex b/lib/jido_code_web/live/agents_live.ex index 674ea34c..a6809c55 100644 --- a/lib/jido_code_web/live/agents_live.ex +++ b/lib/jido_code_web/live/agents_live.ex @@ -1,367 +1,3 @@ defmodule JidoCodeWeb.AgentsLive do - use JidoCodeWeb, :live_view - - alias JidoCode.Agents.SupportAgentConfigs - alias JidoCodeWeb.OperatorShell - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:issue_bot_error, nil) - |> assign( - :issue_bot_supported_webhook_events, - SupportAgentConfigs.supported_issue_bot_webhook_events() - ) - |> assign( - :issue_bot_supported_approval_modes, - SupportAgentConfigs.supported_issue_bot_approval_modes() - ) - |> assign(:project_count, 0) - |> stream(:project_configs, [], reset: true) - |> load_project_configs() - - {:ok, socket} - end - - @impl true - def handle_event( - "set_issue_bot_enabled", - %{"project_id" => project_id, "enabled" => enabled}, - socket - ) do - case SupportAgentConfigs.set_issue_bot_enabled(project_id, enabled) do - {:ok, project_config} -> - {:noreply, - socket - |> assign(:issue_bot_error, nil) - |> stream_insert(:project_configs, project_config)} - - {:error, typed_error} -> - {:noreply, assign(socket, :issue_bot_error, typed_error)} - end - end - - def handle_event("set_issue_bot_enabled", _params, socket) do - {:noreply, - assign(socket, :issue_bot_error, %{ - error_type: "support_agent_config_validation_failed", - detail: "Issue Bot toggle request is missing required parameters.", - remediation: "Select Enable or Disable from a valid project row and retry." - })} - end - - @impl true - def handle_event( - "set_issue_bot_webhook_events", - %{"project_id" => project_id} = params, - socket - ) do - webhook_events = Map.get(params, "webhook_events", []) - - case SupportAgentConfigs.set_issue_bot_webhook_events(project_id, webhook_events) do - {:ok, project_config} -> - {:noreply, - socket - |> assign(:issue_bot_error, nil) - |> stream_insert(:project_configs, project_config)} - - {:error, typed_error} -> - {:noreply, assign(socket, :issue_bot_error, typed_error)} - end - end - - def handle_event("set_issue_bot_webhook_events", _params, socket) do - {:noreply, - assign(socket, :issue_bot_error, %{ - error_type: "support_agent_config_validation_failed", - detail: "Issue Bot webhook event update is missing a project identifier.", - remediation: "Submit webhook event selections from a valid project row and retry." - })} - end - - @impl true - def handle_event( - "set_issue_bot_approval_mode", - %{"project_id" => project_id, "approval_mode" => approval_mode}, - socket - ) do - case SupportAgentConfigs.set_issue_bot_approval_mode(project_id, approval_mode) do - {:ok, project_config} -> - {:noreply, - socket - |> assign(:issue_bot_error, nil) - |> stream_insert(:project_configs, project_config)} - - {:error, typed_error} -> - {:noreply, assign(socket, :issue_bot_error, typed_error)} - end - end - - def handle_event("set_issue_bot_approval_mode", _params, socket) do - {:noreply, - assign(socket, :issue_bot_error, %{ - error_type: "support_agent_config_validation_failed", - detail: "Issue Bot approval mode update is missing required parameters.", - remediation: "Select an approval mode from a valid project row and retry." - })} - end - - @impl true - def render(assigns) do - ~H""" - - <.single_pane_shell id="agents-shell" breadcrumbs={agents_breadcrumbs()} pane={agents_pane()}> - <.operator_state_notice - :if={@issue_bot_error} - id="agents-issue-bot-error" - title="Issue Bot configuration update failed" - state={@issue_bot_error} - kind={:error} - /> - -
- - - - - - - - - - - - - - - - - - - - - - - -
RepositoryIssue Bot statusWebhook eventsApproval policyControls
- No repositories are available for Issue Bot configuration. -
-

- {project_config.github_full_name} -

-

- {project_config.name} -

-
- - {issue_bot_status_label(project_config.enabled)} - - -
- -
- -
- -
-
-

- {issue_bot_approval_mode_label(project_config.approval_policy)} -

-
- - - -
-

- Last updated: {issue_bot_last_updated_label(project_config.last_updated)} -

-
-
- - -
-
-
- -
- """ - end - - defp agents_breadcrumbs do - [ - OperatorShell.breadcrumb(%{ - id: "agents-breadcrumb-current", - label: "Agents", - current?: true - }) - ] - end - - defp agents_pane do - OperatorShell.pane(%{ - id: "agents-pane", - title: "Support agent controls", - summary: "Configure per-project Issue Bot automation controls." - }) - end - - defp load_project_configs(socket) do - case SupportAgentConfigs.list_issue_bot_configs() do - {:ok, project_configs} -> - socket - |> assign(:issue_bot_error, nil) - |> assign(:project_count, length(project_configs)) - |> stream(:project_configs, project_configs, reset: true) - - {:error, typed_error} -> - socket - |> assign(:issue_bot_error, typed_error) - |> assign(:project_count, 0) - |> stream(:project_configs, [], reset: true) - end - end - - defp issue_bot_status_label(true), do: "Enabled" - defp issue_bot_status_label(false), do: "Disabled" - - defp issue_bot_status_class(true), do: "badge badge-success" - defp issue_bot_status_class(false), do: "badge badge-warning" - - defp issue_bot_webhook_event_selected?(webhook_events, event) when is_list(webhook_events) do - event in webhook_events - end - - defp issue_bot_webhook_event_selected?(_webhook_events, _event), do: false - - defp issue_bot_webhook_event_dom_id(event) when is_binary(event) do - event - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/u, "-") - |> String.trim("-") - |> case do - "" -> "unknown" - dom_id -> dom_id - end - end - - defp issue_bot_webhook_event_dom_id(_event), do: "unknown" - - defp issue_bot_approval_mode_label(%{} = approval_policy) do - case map_get(approval_policy, :mode, "mode") do - "auto_post" -> "Auto-post" - "approval_required" -> "Approval required" - _other -> "Approval required" - end - end - - defp issue_bot_approval_mode_label(_approval_policy), do: "Approval required" - - defp issue_bot_approval_mode_option_label("auto_post"), do: "Auto-post" - defp issue_bot_approval_mode_option_label("approval_required"), do: "Approval required" - defp issue_bot_approval_mode_option_label(_approval_mode), do: "Approval required" - - defp issue_bot_approval_mode_selected?(approval_policy, approval_mode) - when is_map(approval_policy) do - map_get(approval_policy, :mode, "mode", "approval_required") == approval_mode - end - - defp issue_bot_approval_mode_selected?(_approval_policy, approval_mode), - do: approval_mode == "approval_required" - - defp issue_bot_last_updated_label(last_updated) when is_map(last_updated) do - case map_get(last_updated, :updated_at, "updated_at") do - updated_at when is_binary(updated_at) -> updated_at - _other -> "unavailable" - end - end - - defp issue_bot_last_updated_label(_last_updated), do: "unavailable" - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default + use JidoCodeWeb.ResetLive, title: "Agents" end diff --git a/lib/jido_code_web/live/dashboard_live.ex b/lib/jido_code_web/live/dashboard_live.ex index 7f477a4a..55bc0be9 100644 --- a/lib/jido_code_web/live/dashboard_live.ex +++ b/lib/jido_code_web/live/dashboard_live.ex @@ -1,1901 +1,3 @@ defmodule JidoCodeWeb.DashboardLive do - # covers: package.jido_code.primary_implementation_repo - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - # covers: architecture.runtime_service_overlay.runtime_topology_details_remain_opaque_to_product - # covers: setup.onboarding.post_bootstrap_surfaces_adopt_control_plane_language - use JidoCodeWeb, :live_view - - import JidoCodeWeb.ManagedRepoInventoryComponents - - alias JidoCode.ManagedRepoRoutes - alias JidoCode.MemoryGraph.DashboardSummaryFeed - alias JidoCode.Governance.RuntimeEvidenceFeed - alias JidoCode.Orchestration.{RunPubSub, RunSummaryFeed} - alias JidoCodeWeb.OperatorShell - - alias JidoCode.Workbench.{ - DashboardConversationFeed, - FixWorkflowKickoff, - InventoryActionState, - InventorySurface, - IssueTriageWorkflowKickoff - } - - @dashboard_subject_order [:work, :knowledge, :runtime] - @dashboard_sections [:overview, :runs, :conversations, :memory, :runtime] - @onboarding_next_actions [ - "Run your first workflow", - "Review the security playbook", - "Test the RPC client" - ] - - @run_events_for_refresh MapSet.new([ - "run_started", - "run_completed", - "run_failed", - "run_cancelled" - ]) - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:selected_dashboard_subject, :work) - |> assign(:selected_dashboard_section, :overview) - |> assign(:dashboard_sections, @dashboard_sections) - |> assign(:onboarding_next_actions, []) - |> assign(:run_summary_count, 0) - |> assign(:run_summary_rows, []) - |> assign(:run_summary_widget_rows, []) - |> assign(:run_summary_warning, nil) - |> assign(:run_summary_last_refreshed_at, nil) - |> assign(:conversation_summary_count, 0) - |> assign(:conversation_summary_rows, []) - |> assign(:conversation_summary_warning, nil) - |> assign(:conversation_summary_last_refreshed_at, nil) - |> assign(:runtime_evidence_count, 0) - |> assign(:runtime_evidence_rows, []) - |> assign(:runtime_evidence_widget_rows, []) - |> assign(:runtime_evidence_warning, nil) - |> assign(:runtime_evidence_last_refreshed_at, nil) - |> assign(:runtime_evidence_summary, nil) - |> assign(:memory_summary_count, 0) - |> assign(:memory_summary_rows, []) - |> assign(:memory_summary_warning, nil) - |> assign(:memory_summary_last_refreshed_at, nil) - |> assign(:repository_monitoring_count, 0) - |> assign(:repository_monitoring_rows, []) - |> assign(:repository_monitoring_warning, nil) - |> assign(:repository_monitoring_last_refreshed_at, nil) - |> assign(:recent_run_outcomes, %{}) - |> assign(:fix_workflow_kickoff_states, %{}) - |> assign(:issue_triage_workflow_kickoff_states, %{}) - |> stream_configure(:conversation_summaries, dom_id: &conversation_summary_dom_id/1) - |> stream(:conversation_summaries, [], reset: true) - |> stream_configure(:memory_summaries, dom_id: &memory_summary_dom_id/1) - |> stream(:memory_summaries, [], reset: true) - |> stream_configure(:runtime_evidence_summaries, dom_id: &runtime_evidence_dom_id/1) - |> stream(:runtime_evidence_summaries, [], reset: true) - |> stream_configure(:run_summaries, dom_id: &run_summary_dom_id/1) - |> stream(:run_summaries, [], reset: true) - |> load_run_summaries() - |> load_conversation_summaries() - |> load_memory_summaries() - |> load_runtime_evidence_summaries() - |> load_repository_monitoring_summaries() - |> maybe_subscribe_run_events() - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - onboarding_next_actions = - if Map.get(params, "onboarding") == "completed" do - @onboarding_next_actions - else - [] - end - - dashboard_sections = dashboard_sections(onboarding_next_actions) - - {selected_dashboard_subject, selected_dashboard_section} = - normalize_dashboard_selection(params, onboarding_next_actions) - - socket = - socket - |> assign(:onboarding_next_actions, onboarding_next_actions) - |> assign(:dashboard_sections, dashboard_sections) - |> assign(:selected_dashboard_subject, selected_dashboard_subject) - |> assign(:selected_dashboard_section, selected_dashboard_section) - - {:noreply, - assign( - socket, - :run_summary_widget_rows, - Enum.map(Map.get(socket.assigns, :run_summary_rows, []), &dashboard_run_summary_widget(socket.assigns, &1)) - )} - end - - @impl true - def handle_event("refresh_dashboard_overview", _params, socket) do - {:noreply, - socket - |> load_run_summaries() - |> load_conversation_summaries() - |> load_memory_summaries() - |> load_runtime_evidence_summaries() - |> load_repository_monitoring_summaries()} - end - - @impl true - def handle_event("refresh_run_summaries", _params, socket) do - {:noreply, socket |> load_run_summaries() |> load_repository_monitoring_summaries()} - end - - @impl true - def handle_event("refresh_conversation_summaries", _params, socket) do - {:noreply, socket |> load_conversation_summaries() |> load_repository_monitoring_summaries()} - end - - @impl true - def handle_event("refresh_runtime_evidence", _params, socket) do - {:noreply, socket |> load_runtime_evidence_summaries() |> load_repository_monitoring_summaries()} - end - - @impl true - def handle_event("refresh_memory_summaries", _params, socket) do - {:noreply, socket |> load_memory_summaries() |> load_repository_monitoring_summaries()} - end - - @impl true - def handle_event( - "kickoff_fix_workflow", - %{"project_id" => project_id, "context_item_type" => context_item_type}, - socket - ) do - project_row = find_project_row(socket.assigns.repository_monitoring_rows, project_id) - return_to = current_dashboard_path(socket.assigns) - - kickoff_result = - FixWorkflowKickoff.kickoff( - project_row, - context_item_type, - initiating_actor(socket), - return_to: return_to - ) - - {:noreply, - socket - |> put_fix_workflow_kickoff_state(project_id, context_item_type, kickoff_result, return_to) - |> put_recent_run_outcome_from_kickoff(project_id, kickoff_result, return_to)} - end - - @impl true - def handle_event( - "kickoff_issue_triage_workflow", - %{"project_id" => project_id, "context_item_type" => context_item_type}, - socket - ) do - project_row = find_project_row(socket.assigns.repository_monitoring_rows, project_id) - - kickoff_result = - IssueTriageWorkflowKickoff.kickoff( - project_row, - context_item_type, - initiating_actor(socket), - return_to: current_dashboard_path(socket.assigns) - ) - - {:noreply, - socket - |> put_issue_triage_workflow_kickoff_state( - project_id, - context_item_type, - kickoff_result, - current_dashboard_path(socket.assigns) - ) - |> put_recent_run_outcome_from_kickoff( - project_id, - kickoff_result, - current_dashboard_path(socket.assigns) - )} - end - - @impl true - def handle_info({:run_event, payload}, socket) do - event_name = - payload - |> map_get(:event, "event") - |> normalize_optional_string() - - if MapSet.member?(@run_events_for_refresh, event_name) do - {:noreply, - socket - |> load_run_summaries() - |> load_conversation_summaries() - |> load_memory_summaries() - |> load_runtime_evidence_summaries() - |> load_repository_monitoring_summaries()} - else - {:noreply, socket} - end - end - - @impl true - def render(assigns) do - ~H""" - -
-

Dashboard

-

Welcome, {@current_user.email}

-

- Dashboard is the authenticated product home for managed-repository inventory, governed runs, conversations, memory, and runtime posture. -

-

- Provider login and Git automation configuration live in <.link - navigate={~p"/settings/auth"} - class="link link-primary" - >Settings. -

- - <.subject_tree_shell - id="dashboard-shell" - class="mt-6" - breadcrumbs={dashboard_breadcrumbs(assigns)} - parent_subjects={dashboard_parent_subjects(assigns)} - child_subjects={dashboard_section_nav_items(assigns)} - child_nav_id="dashboard-section-nav" - child_nav_label={dashboard_child_nav_label(assigns.selected_dashboard_subject)} - child_nav_heading={dashboard_child_nav_heading(assigns.selected_dashboard_subject)} - child_nav_summary={dashboard_child_nav_summary(assigns.selected_dashboard_subject)} - sidebar_id="dashboard-sidebar-shell" - content_id="dashboard-content-shell" - > - <.subject_pane pane={dashboard_selected_pane(assigns)}> -
-
-
-

Managed repo inventory

-

- Dashboard Work is the primary signed-in home for repository inventory, governed triage, and repo-detail follow-up. - <.link - id="dashboard-overview-workbench-link" - navigate={dashboard_workbench_path(assigns)} - class="link link-primary" - > - Workbench - - stays available as the denser specialist mode. -

-
-

- Last refreshed: {summary_refreshed_label(@repository_monitoring_last_refreshed_at)} -

-
- - <.operator_state_notice - :if={@repository_monitoring_warning} - id="dashboard-overview-warning" - title="Managed repo inventory may be stale" - state={@repository_monitoring_warning} - compact={true} - /> - - <%= if @repository_monitoring_count == 0 do %> -

- No managed-repository inventory is available yet. -

- <% else %> -
    -
  1. -
    -
    -

    - {entry.github_full_name} -

    -

    - {entry.name} -

    -
    - -
    - - {entry.open_issue_count} open {pluralize(entry.open_issue_count, "issue", "issues")} - - - {entry.open_pr_count} open PR{if(entry.open_pr_count == 1, do: "", else: "s")} - - - {runtime_badge.label} - -
    -
    - -

    - {entry.recent_activity_summary} -

    - -
    -

    - Branch: {entry.default_branch} -

    -

    - {dashboard_inventory_activity_label(entry)} -

    -
    - - <.managed_repo_hint_stack - row={entry} - dom_prefix="dashboard-overview-repository" - detail_path={dashboard_inventory_detail_path(assigns, entry)} - /> - -
    - <.managed_repo_action_cluster - row={entry} - dom_prefix="dashboard-overview-repository-issues" - detail_path={dashboard_inventory_detail_path(assigns, entry)} - kind={:issue} - recent_run_outcome={InventorySurface.recent_run_outcome(@recent_run_outcomes, entry.id)} - triage_policy_state={InventorySurface.issue_triage_policy_state(entry)} - issue_triage_feedback={ - InventoryActionState.issue_triage_feedback( - @issue_triage_workflow_kickoff_states, - entry.id, - :issue - ) - } - fix_feedback={ - InventoryActionState.fix_feedback( - @fix_workflow_kickoff_states, - entry.id, - :issue - ) - } - /> - - <.managed_repo_action_cluster - row={entry} - dom_prefix="dashboard-overview-repository-prs" - detail_path={dashboard_inventory_detail_path(assigns, entry)} - kind={:pull_request} - recent_run_outcome={InventorySurface.recent_run_outcome(@recent_run_outcomes, entry.id)} - fix_feedback={ - InventoryActionState.fix_feedback( - @fix_workflow_kickoff_states, - entry.id, - :pull_request - ) - } - /> -
    -
  2. -
- <% end %> -
- -
-
-

Recent governed runs

-

- Last refreshed: {summary_refreshed_label(@run_summary_last_refreshed_at)} -

-
- - <.operator_state_notice - :if={@run_summary_warning} - id="dashboard-run-summary-warning" - title="Run summary feed may be stale" - state={@run_summary_warning} - compact={true} - /> - - <.vue_surface - id="dashboard-run-summary-widget" - component="DashboardRunSummaryWidget" - socket={@socket} - props={ - %{ - runSummaries: @run_summary_widget_rows, - runSummaryCount: @run_summary_count, - lastRefreshedLabel: summary_refreshed_label(@run_summary_last_refreshed_at) - } - } - /> - -
- - LiveView detail fallback - -
- - - - - - - - - - - - - - - - - - - - -
RunStatusRecency
- No recent runs available. -
- <.link - id={"dashboard-run-link-#{run_summary_dom_token(run_summary.run_id)}"} - class="link link-primary" - navigate={run_detail_path(assigns, run_summary)} - > - {run_summary.run_id} - -

{run_summary.workflow_name}

-
- - {run_summary.status} - -

- {run_governance_summary(run_summary)} -

-
- {run_recency_label(run_summary)} -
-
-
-
- -
-
-
-

Conversation supervision

-

- Active governed conversations and clarification-needed repository work stay bounded here and route back to canonical repo detail. -

-
-

- Last refreshed: {summary_refreshed_label(@conversation_summary_last_refreshed_at)} -

-
- - <.operator_state_notice - :if={@conversation_summary_warning} - id="dashboard-conversation-summary-warning" - title="Conversation supervision may be stale" - state={@conversation_summary_warning} - compact={true} - /> - - <%= if @conversation_summary_count == 0 do %> -

- No active governed conversations or clarification-needed repository work are available yet. -

- <% else %> -
    -
  1. -
    -
    -

    - {summary.repo_label} -

    -

    - {summary.detail} -

    -
    -
    - <.conversation_role_badge - id={"dashboard-conversation-summary-role-#{run_summary_dom_token(summary.id)}"} - scope={summary.role_scope} - attachment_mode={summary.role_attachment_mode} - work_item_id={summary.role_work_item_id} - /> - <.conversation_status_badge - :if={summary.latest_status} - id={"dashboard-conversation-summary-status-#{run_summary_dom_token(summary.id)}"} - status={summary.latest_status} - /> - 0} - id={"dashboard-conversation-summary-clarification-#{run_summary_dom_token(summary.id)}"} - class="badge badge-warning badge-outline badge-sm font-medium" - > - {conversation_clarification_badge(summary.clarification_count)} - -
    -
    - -

    - Active governed conversations: {summary.active_count} | Clarification needed: {summary.clarification_count} -

    - -

    - Latest governed work: {summary.latest_work_item_summary} -

    - -

    - Latest activity: {summary_refreshed_label(summary.latest_activity_at)} -

    - - <.link - :if={summary.route && summary.action_label} - id={"dashboard-conversation-summary-link-#{run_summary_dom_token(summary.id)}"} - class="link link-primary text-sm" - navigate={summary.route} - > - {summary.action_label} - -
  2. -
- <% end %> -
- -
-
-
-

Repository memory

-

- Memory summaries stay bounded and route back to canonical managed-repository surfaces for deeper review. -

-
-

- Last refreshed: {summary_refreshed_label(@memory_summary_last_refreshed_at)} -

-
- - <.operator_state_notice - :if={@memory_summary_warning} - id="dashboard-memory-summary-warning" - title="Repository memory summaries may be stale" - state={@memory_summary_warning} - compact={true} - /> - - <%= if @memory_summary_count == 0 do %> -

- No bounded repository memory summaries are available yet. -

- <% else %> -
    -
  1. -
    -
    -

    - {summary.repo_label} -

    -

    - {summary.detail} -

    -
    - - {summary.label} - -
    - -

    - Durable memory: {summary.memory_count} | Workflow provenance: {summary.provenance_count} -

    - -

    - Latest revision: {summary.latest_revision} -

    - -

    - {summary.remediation} -

    - -
    - - action needed - - - <.link - :if={summary.route && summary.action_label} - id={"dashboard-memory-summary-link-#{run_summary_dom_token(summary.id)}"} - class="link link-primary text-sm" - navigate={summary.route} - > - {summary.action_label} - -
    -
  2. -
- <% end %> -
- -
-
-
-

Runtime posture

-

- {runtime_evidence_summary(@runtime_evidence_summary)} -

-

- Product records remain the source of truth; runtime posture only contributes bounded readiness and degraded-path evidence. -

-
-

- Last refreshed: {summary_refreshed_label(@runtime_evidence_last_refreshed_at)} -

-
- - <.operator_state_notice - :if={@runtime_evidence_warning} - id="dashboard-runtime-evidence-warning" - title="Runtime posture may be stale" - state={@runtime_evidence_warning} - compact={true} - /> - - <.vue_surface - id="dashboard-runtime-posture-widget" - component="DashboardRuntimePostureWidget" - socket={@socket} - props={ - %{ - counts: runtime_evidence_widget_counts(@runtime_evidence_summary), - runtimeEvidenceSummaries: @runtime_evidence_widget_rows - } - } - /> - -
- - LiveView posture details - - -
-
-
-

Blocked repos

-

- {Map.get(@runtime_evidence_summary, :blocked_count, 0)} -

-
-
-

Review-required repos

-

- {Map.get(@runtime_evidence_summary, :degraded_count, 0)} -

-
-
-

Stable repos

-

- {Map.get(@runtime_evidence_summary, :available_count, 0)} -

-
-
- - <%= if @runtime_evidence_count == 0 do %> -

- No runtime-service posture has been materialized yet. -

- <% else %> -
    -
  1. -
    -

    {runtime_summary.repo_label}

    - - {runtime_evidence_status_label(runtime_summary.status)} - - - review required - -
    -

    - {runtime_summary.summary} -

    -

    - {runtime_evidence_details(runtime_summary)} -

    -
  2. -
- <% end %> -
-
-
- -
-
-

Suggested next actions

-

- These follow-up actions remain bounded onboarding completion cues, not a permanent dashboard concern when no setup follow-up exists. -

-
-
    -
  • - {next_action} -
  • -
-
- <:footer_actions> - <.link - :if={@selected_dashboard_section == :overview} - id="dashboard-overview-open-workbench" - navigate={dashboard_workbench_path(assigns)} - class="btn btn-sm btn-outline" - > - Open dense Workbench mode - - - - - - - - - - - - - - -
-
- """ - end - - defp load_run_summaries(socket) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - case RunSummaryFeed.load() do - {:ok, run_summaries, warning} -> - socket - |> assign(:run_summary_count, length(run_summaries)) - |> assign(:run_summary_rows, run_summaries) - |> assign( - :run_summary_widget_rows, - Enum.map(run_summaries, &dashboard_run_summary_widget(socket.assigns, &1)) - ) - |> assign(:run_summary_warning, warning) - |> assign(:run_summary_last_refreshed_at, now) - |> stream(:run_summaries, run_summaries, reset: true) - - {:error, warning} -> - socket - |> assign(:run_summary_count, 0) - |> assign(:run_summary_rows, []) - |> assign(:run_summary_widget_rows, []) - |> assign(:run_summary_warning, warning) - |> assign(:run_summary_last_refreshed_at, now) - |> stream(:run_summaries, [], reset: true) - end - end - - defp load_conversation_summaries(socket) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - case DashboardConversationFeed.load() do - {:ok, summaries, warning} -> - socket - |> assign(:conversation_summary_count, length(summaries)) - |> assign(:conversation_summary_rows, summaries) - |> assign(:conversation_summary_warning, warning) - |> assign(:conversation_summary_last_refreshed_at, now) - |> stream(:conversation_summaries, summaries, reset: true) - - {:error, warning} -> - socket - |> assign(:conversation_summary_count, 0) - |> assign(:conversation_summary_rows, []) - |> assign(:conversation_summary_warning, warning) - |> assign(:conversation_summary_last_refreshed_at, now) - |> stream(:conversation_summaries, [], reset: true) - end - end - - defp maybe_subscribe_run_events(socket) do - if connected?(socket) do - :ok = RunPubSub.subscribe_runs() - socket - else - socket - end - end - - defp dashboard_sections(onboarding_next_actions) when is_list(onboarding_next_actions) do - if Enum.empty?(onboarding_next_actions) do - @dashboard_sections - else - @dashboard_sections ++ [:next_steps] - end - end - - defp dashboard_subject_sections(onboarding_next_actions) when is_list(onboarding_next_actions) do - %{ - work: - if(Enum.empty?(onboarding_next_actions), - do: [:overview, :runs, :conversations], - else: [:overview, :runs, :conversations, :next_steps] - ), - knowledge: [:memory], - runtime: [:runtime] - } - end - - defp dashboard_section_nav_items(assigns) do - assigns.onboarding_next_actions - |> dashboard_subject_sections() - |> Map.fetch!(assigns.selected_dashboard_subject) - |> Enum.map(fn section -> - OperatorShell.child_subject(%{ - id: section, - label: dashboard_section_label(section), - summary: dashboard_section_summary(section, assigns), - badge: dashboard_section_badge(section, assigns), - pane_id: "dashboard-pane-#{section}", - selected?: assigns.selected_dashboard_section == section, - patch: - dashboard_selection_path( - assigns.onboarding_next_actions, - assigns.selected_dashboard_subject, - section - ) - }) - |> Map.put(:section, section) - end) - end - - defp dashboard_breadcrumbs(assigns) do - [ - OperatorShell.breadcrumb(%{ - id: "dashboard-breadcrumb-dashboard", - label: "Dashboard", - patch: dashboard_selection_path(assigns.onboarding_next_actions, :work, :overview) - }), - OperatorShell.breadcrumb(%{ - id: "dashboard-breadcrumb-subject", - label: dashboard_subject_label(assigns.selected_dashboard_subject), - patch: - dashboard_subject_path( - assigns.onboarding_next_actions, - assigns.selected_dashboard_subject - ) - }), - OperatorShell.breadcrumb(%{ - id: "dashboard-breadcrumb-current", - label: dashboard_section_label(assigns.selected_dashboard_section), - current?: true - }) - ] - end - - defp dashboard_parent_subjects(assigns) do - assigns.onboarding_next_actions - |> dashboard_subject_sections() - |> then(fn subject_sections -> - Enum.map(@dashboard_subject_order, fn subject -> - OperatorShell.parent_subject(%{ - id: subject, - label: dashboard_subject_label(subject), - description: dashboard_subject_description(subject), - selected?: assigns.selected_dashboard_subject == subject, - patch: dashboard_subject_path(assigns.onboarding_next_actions, subject) - }) - end) - |> Enum.filter(fn subject -> Map.has_key?(subject_sections, subject.id) end) - end) - end - - defp dashboard_selected_pane(assigns) do - section = assigns.selected_dashboard_section - - OperatorShell.pane(%{ - id: "dashboard-pane-#{section}", - title: dashboard_pane_title(section), - summary: dashboard_pane_summary(section) - }) - end - - defp dashboard_child_nav_label(:work), do: "Dashboard work subjects" - defp dashboard_child_nav_label(:knowledge), do: "Dashboard knowledge subjects" - defp dashboard_child_nav_label(:runtime), do: "Dashboard runtime subjects" - - defp dashboard_child_nav_heading(:work), do: "Work" - defp dashboard_child_nav_heading(:knowledge), do: "Knowledge" - defp dashboard_child_nav_heading(:runtime), do: "Runtime" - - defp dashboard_child_nav_summary(:work) do - "Primary managed-repository inventory, governed runs, governed conversations, and ready follow-up stay grouped here without leaving the authenticated landing route." - end - - defp dashboard_child_nav_summary(:knowledge) do - "Bounded memory attention stays here and routes back to canonical managed-repository detail when a deeper review is needed." - end - - defp dashboard_child_nav_summary(:runtime) do - "Delivery readiness and degraded-path runtime posture stay grouped here as bounded product signals." - end - - defp dashboard_subject_path(onboarding_next_actions, subject) do - dashboard_selection_path( - onboarding_next_actions, - subject, - default_dashboard_section_for_subject(subject, onboarding_next_actions) - ) - end - - defp dashboard_selection_path(onboarding_next_actions, subject, section) do - params = - [subject: Atom.to_string(subject), section: Atom.to_string(section)] - |> maybe_put_query_param(:onboarding, onboarding_next_actions_query_param(onboarding_next_actions)) - - ~p"/dashboard?#{params}" - end - - defp onboarding_next_actions_query_param(onboarding_next_actions) do - if Enum.empty?(onboarding_next_actions), do: nil, else: "completed" - end - - defp normalize_dashboard_selection(params, onboarding_next_actions) do - subject_sections = dashboard_subject_sections(onboarding_next_actions) - available_sections = dashboard_sections(onboarding_next_actions) - - case normalize_dashboard_section(Map.get(params, "section"), available_sections) do - section when is_atom(section) -> - {dashboard_subject_for_section(section, onboarding_next_actions), section} - - nil -> - subject = - params - |> Map.get("subject") - |> parse_dashboard_subject(subject_sections) - - {subject, default_dashboard_section_for_subject(subject, onboarding_next_actions)} - end - end - - defp parse_dashboard_subject(subject_param, subject_sections) when is_map(subject_sections) do - available_subjects = Map.keys(subject_sections) - - case normalize_optional_string(subject_param) do - nil -> - :work - - normalized -> - normalized - |> String.trim() - |> String.downcase() - |> String.replace("-", "_") - |> case do - "work" -> :work - "knowledge" -> :knowledge - "runtime" -> :runtime - _other -> :work - end - |> then(fn subject -> if subject in available_subjects, do: subject, else: :work end) - end - end - - defp normalize_dashboard_section(section_param, available_sections) do - parse_dashboard_section(section_param, available_sections) || :overview - end - - defp parse_dashboard_section(section_param, available_sections) do - section_param - |> normalize_optional_string() - |> case do - nil -> - nil - - normalized -> - normalized - |> String.trim() - |> String.downcase() - |> String.replace("-", "_") - |> case do - "next_steps" -> :next_steps - "overview" -> :overview - "runs" -> :runs - "conversations" -> :conversations - "memory" -> :memory - "runtime" -> :runtime - _other -> nil - end - end - |> then(fn section -> - if section in available_sections, do: section, else: nil - end) - end - - defp default_dashboard_section_for_subject(subject, onboarding_next_actions) do - onboarding_next_actions - |> dashboard_subject_sections() - |> Map.fetch!(subject) - |> List.first() - end - - defp dashboard_subject_for_section(section, onboarding_next_actions) do - onboarding_next_actions - |> dashboard_subject_sections() - |> Enum.find_value(:work, fn {subject, sections} -> - if section in sections, do: subject, else: nil - end) - end - - defp dashboard_subject_label(:work), do: "Work" - defp dashboard_subject_label(:knowledge), do: "Knowledge" - defp dashboard_subject_label(:runtime), do: "Runtime" - - defp dashboard_subject_description(:work) do - "Managed-repository inventory, governed activity, and ready follow-up stay grouped here." - end - - defp dashboard_subject_description(:knowledge) do - "Bounded memory attention and repo knowledge signals stay grouped here." - end - - defp dashboard_subject_description(:runtime) do - "Delivery readiness and degraded-path runtime posture stay grouped here." - end - - defp dashboard_section_label(:overview), do: "Overview" - defp dashboard_section_label(:runs), do: "Runs" - defp dashboard_section_label(:conversations), do: "Conversations" - defp dashboard_section_label(:memory), do: "Memory" - defp dashboard_section_label(:runtime), do: "Posture" - defp dashboard_section_label(:next_steps), do: "Follow-up" - - defp dashboard_pane_title(:overview), do: "Managed repo inventory" - defp dashboard_pane_title(:runs), do: "Recent governed runs" - defp dashboard_pane_title(:conversations), do: "Conversation supervision" - defp dashboard_pane_title(:memory), do: "Repository memory" - defp dashboard_pane_title(:runtime), do: "Runtime posture" - defp dashboard_pane_title(:next_steps), do: "Suggested next actions" - - defp dashboard_pane_summary(:overview), - do: - "Dashboard Work owns the primary managed-repository inventory here while Workbench remains the denser specialist mode." - - defp dashboard_pane_summary(:runs), - do: "Review bounded governed-run status here without leaving the authenticated landing route." - - defp dashboard_pane_summary(:conversations), - do: "Active governed conversations stay bounded here and route back to canonical repository detail." - - defp dashboard_pane_summary(:memory), - do: "Memory summaries remain bounded and action-oriented here rather than becoming parallel product truth." - - defp dashboard_pane_summary(:runtime), - do: "Runtime rollout and recovery posture stay product-shaped here instead of exposing runtime internals." - - defp dashboard_pane_summary(:next_steps), - do: "Post-onboarding follow-up remains bounded here instead of replacing the durable landing route." - - defp dashboard_section_summary(:overview, assigns) do - if assigns.repository_monitoring_count == 0 do - "No managed-repository inventory is visible yet." - else - "#{assigns.repository_monitoring_count} managed repositories ready for inventory and triage review." - end - end - - defp dashboard_section_summary(:runs, assigns) do - if assigns.run_summary_count == 0 do - "No recent governed runs are visible yet." - else - "#{assigns.run_summary_count} recent governed run #{pluralize(assigns.run_summary_count, "summary", "summaries")}." - end - end - - defp dashboard_section_summary(:conversations, assigns) do - clarification_count = conversation_clarification_total(assigns.conversation_summary_rows) - - cond do - assigns.conversation_summary_count == 0 -> - "No active governed conversation supervision is visible yet." - - clarification_count > 0 -> - "#{assigns.conversation_summary_count} repo #{pluralize(assigns.conversation_summary_count, "summary", "summaries")} with #{clarification_count} clarification #{pluralize(clarification_count, "turn", "turns")} pending." - - true -> - "#{assigns.conversation_summary_count} repo #{pluralize(assigns.conversation_summary_count, "summary", "summaries")} with active governed work." - end - end - - defp dashboard_section_summary(:memory, assigns) do - action_needed_count = memory_action_needed_count(assigns.memory_summary_rows) - - cond do - assigns.memory_summary_count == 0 -> - "No bounded memory summaries are visible yet." - - action_needed_count > 0 -> - "#{assigns.memory_summary_count} repo #{pluralize(assigns.memory_summary_count, "summary", "summaries")} and #{action_needed_count} requiring operator follow-up." - - true -> - "#{assigns.memory_summary_count} repository memory #{pluralize(assigns.memory_summary_count, "summary", "summaries")} are available for review." - end - end - - defp dashboard_section_summary(:runtime, assigns), - do: runtime_evidence_summary(assigns.runtime_evidence_summary) - - defp dashboard_section_summary(:next_steps, assigns) do - count = length(assigns.onboarding_next_actions) - "#{count} ready-state follow-up #{pluralize(count, "action", "actions")}." - end - - defp dashboard_section_badge(:overview, assigns) do - cond do - assigns.repository_monitoring_warning -> - %{label: "stale", tone: :warning} - - assigns.repository_monitoring_count > 0 -> - %{label: Integer.to_string(assigns.repository_monitoring_count), tone: :neutral} - - true -> - nil - end - end - - defp dashboard_section_badge(:runs, assigns) do - if assigns.run_summary_count > 0 do - %{label: Integer.to_string(assigns.run_summary_count), tone: :neutral} - else - nil - end - end - - defp dashboard_section_badge(:conversations, assigns) do - clarification_count = conversation_clarification_total(assigns.conversation_summary_rows) - - cond do - clarification_count > 0 -> - %{label: "#{clarification_count} waiting", tone: :warning} - - assigns.conversation_summary_count > 0 -> - %{label: Integer.to_string(assigns.conversation_summary_count), tone: :neutral} - - true -> - nil - end - end - - defp dashboard_section_badge(:memory, assigns) do - action_needed_count = memory_action_needed_count(assigns.memory_summary_rows) - - cond do - action_needed_count > 0 -> - %{label: "#{action_needed_count} action", tone: :warning} - - assigns.memory_summary_count > 0 -> - %{label: Integer.to_string(assigns.memory_summary_count), tone: :neutral} - - true -> - nil - end - end - - defp dashboard_section_badge(:runtime, assigns) do - case runtime_attention_badge(assigns.runtime_evidence_summary) do - nil -> nil - badge -> badge - end - end - - defp dashboard_section_badge(:next_steps, assigns) do - count = length(assigns.onboarding_next_actions) - - if count > 0 do - %{label: Integer.to_string(count), tone: :warning} - else - nil - end - end - - defp summary_refreshed_label(%DateTime{} = datetime) do - datetime - |> DateTime.truncate(:second) - |> DateTime.to_iso8601() - end - - defp summary_refreshed_label(_datetime), do: "not yet" - - defp run_summary_dom_id(run_summary) do - "dashboard-run-summary-#{run_summary_dom_token(run_summary.id)}" - end - - defp conversation_summary_dom_id(summary) do - "dashboard-conversation-summary-#{run_summary_dom_token(summary.id)}" - end - - defp runtime_evidence_dom_id(runtime_summary) do - "dashboard-runtime-evidence-#{run_summary_dom_token(runtime_summary.id)}" - end - - defp memory_summary_dom_id(summary) do - "dashboard-memory-summary-#{run_summary_dom_token(summary.id)}" - end - - defp repo_monitoring_dom_token(value), do: run_summary_dom_token(value) - - defp run_summary_dom_token(value) do - value - |> normalize_optional_string() - |> case do - nil -> "unknown" - token -> token - end - |> String.replace(~r/[^a-zA-Z0-9_-]/, "-") - end - - defp run_status_badge_class("completed"), do: "badge badge-success" - defp run_status_badge_class("running"), do: "badge badge-info" - defp run_status_badge_class("failed"), do: "badge badge-error" - defp run_status_badge_class("cancelled"), do: "badge badge-warning" - defp run_status_badge_class("awaiting_approval"), do: "badge badge-warning" - defp run_status_badge_class("pending"), do: "badge badge-outline" - defp run_status_badge_class(_status), do: "badge badge-outline" - - defp runtime_evidence_badge_class("blocked"), do: "badge badge-error" - defp runtime_evidence_badge_class("degraded"), do: "badge badge-warning" - defp runtime_evidence_badge_class("available"), do: "badge badge-success" - defp runtime_evidence_badge_class(_status), do: "badge badge-outline" - - defp memory_summary_badge_class(:ready), do: "badge badge-success" - defp memory_summary_badge_class(:degraded), do: "badge badge-warning" - defp memory_summary_badge_class(:stale), do: "badge badge-warning" - defp memory_summary_badge_class(:invalidated), do: "badge badge-warning" - defp memory_summary_badge_class(:failed), do: "badge badge-error" - defp memory_summary_badge_class(:not_ready), do: "badge badge-outline" - defp memory_summary_badge_class(:disabled), do: "badge badge-outline" - defp memory_summary_badge_class(_state), do: "badge badge-outline" - - defp runtime_evidence_status_label(status) do - case normalize_optional_string(status) do - "blocked" -> "blocked" - "degraded" -> "review required" - "available" -> "stable" - nil -> "unknown" - other -> other - end - end - - defp run_recency_label(run_summary) do - case Map.get(run_summary, :started_at) do - %DateTime{} = started_at -> - started_iso8601 = DateTime.to_iso8601(DateTime.truncate(started_at, :second)) - "Started #{relative_time_label(started_at)} (#{started_iso8601})" - - _other -> - "Recency unavailable" - end - end - - defp run_governance_summary(run_summary) do - evidence_count = - run_summary - |> Map.get(:evidence_count, 0) - |> normalize_non_negative_integer() - - current_stage = - run_summary - |> Map.get(:current_stage) - |> normalize_optional_string() - - change_request_status = - run_summary - |> Map.get(:change_request_status) - |> normalize_optional_string() - - latest_decision = - run_summary - |> Map.get(:latest_decision) - |> normalize_optional_string() - - [ - current_stage && "Stage: #{current_stage}", - evidence_count > 0 && "Evidence: #{evidence_count}", - change_request_status && "Review: #{change_request_status}", - latest_decision && "Decision: #{latest_decision}" - ] - |> Enum.reject(&is_nil/1) - |> case do - [] -> nil - parts -> Enum.join(parts, " · ") - end - end - - defp runtime_evidence_summary(%{} = summary) do - "Runtime posture tracks #{Map.get(summary, :total_count, 0)} repo(s): #{Map.get(summary, :blocked_count, 0)} blocked, #{Map.get(summary, :degraded_count, 0)} review-required, #{Map.get(summary, :available_count, 0)} stable." - end - - defp runtime_evidence_summary(_summary), - do: "Runtime posture status is unavailable." - - defp runtime_attention_badge(%{} = summary) do - blocked = Map.get(summary, :blocked_count, 0) - degraded = Map.get(summary, :degraded_count, 0) - - cond do - blocked > 0 -> %{label: "#{blocked} blocked", tone: :warning} - degraded > 0 -> %{label: "#{degraded} review", tone: :warning} - Map.get(summary, :total_count, 0) > 0 -> %{label: "#{Map.get(summary, :total_count, 0)} repos", tone: :neutral} - true -> nil - end - end - - defp runtime_attention_badge(_summary), do: nil - - defp conversation_clarification_badge(1), do: "1 clarification needed" - defp conversation_clarification_badge(count), do: "#{count} clarifications needed" - - defp conversation_clarification_total(summaries) when is_list(summaries) do - Enum.reduce(summaries, 0, fn summary, total -> - total + normalize_non_negative_integer(Map.get(summary, :clarification_count, 0)) - end) - end - - defp memory_action_needed_count(summaries) when is_list(summaries) do - Enum.count(summaries, &Map.get(&1, :action_needed?, false)) - end - - defp runtime_evidence_details(runtime_summary) do - [ - runtime_summary.delivery_mode && "Delivery: #{humanize_runtime_value(runtime_summary.delivery_mode)}", - runtime_summary.reason_code && "Reason: #{humanize_runtime_value(runtime_summary.reason_code)}", - runtime_summary.latest_provider && "Latest provider: #{runtime_summary.latest_provider}", - runtime_summary.supervision_mode && "Supervision: #{runtime_summary.supervision_mode}" - ] - |> Enum.reject(&is_nil/1) - |> case do - [] -> "No additional runtime posture details." - parts -> Enum.join(parts, " · ") - end - end - - defp load_runtime_evidence_summaries(socket) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - case RuntimeEvidenceFeed.load() do - {:ok, summaries, warning} -> - socket - |> assign(:runtime_evidence_count, length(summaries)) - |> assign(:runtime_evidence_rows, summaries) - |> assign(:runtime_evidence_widget_rows, Enum.map(summaries, &dashboard_runtime_evidence_widget/1)) - |> assign(:runtime_evidence_warning, warning) - |> assign(:runtime_evidence_last_refreshed_at, now) - |> assign(:runtime_evidence_summary, summarize_runtime_evidence(summaries)) - |> stream(:runtime_evidence_summaries, summaries, reset: true) - - {:error, warning} -> - socket - |> assign(:runtime_evidence_count, 0) - |> assign(:runtime_evidence_rows, []) - |> assign(:runtime_evidence_widget_rows, []) - |> assign(:runtime_evidence_warning, warning) - |> assign(:runtime_evidence_last_refreshed_at, now) - |> assign(:runtime_evidence_summary, nil) - |> stream(:runtime_evidence_summaries, [], reset: true) - end - end - - defp load_repository_monitoring_summaries(socket) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - case InventorySurface.load(current_dashboard_path(socket.assigns)) do - {:ok, rows, recent_run_outcomes, warning} -> - socket - |> assign(:repository_monitoring_count, length(rows)) - |> assign(:repository_monitoring_rows, rows) - |> assign(:repository_monitoring_warning, warning) - |> assign(:repository_monitoring_last_refreshed_at, now) - |> assign(:recent_run_outcomes, recent_run_outcomes) - - {:error, warning} -> - socket - |> assign(:repository_monitoring_count, 0) - |> assign(:repository_monitoring_rows, []) - |> assign(:repository_monitoring_warning, warning) - |> assign(:repository_monitoring_last_refreshed_at, now) - |> assign(:recent_run_outcomes, %{}) - end - end - - defp load_memory_summaries(socket) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - case DashboardSummaryFeed.load() do - {:ok, summaries, warning} -> - socket - |> assign(:memory_summary_count, length(summaries)) - |> assign(:memory_summary_rows, summaries) - |> assign(:memory_summary_warning, warning) - |> assign(:memory_summary_last_refreshed_at, now) - |> stream(:memory_summaries, summaries, reset: true) - - {:error, warning} -> - socket - |> assign(:memory_summary_count, 0) - |> assign(:memory_summary_rows, []) - |> assign(:memory_summary_warning, warning) - |> assign(:memory_summary_last_refreshed_at, now) - |> stream(:memory_summaries, [], reset: true) - end - end - - defp dashboard_run_summary_widget(assigns, run_summary) do - status = - run_summary - |> Map.get(:status) - |> normalize_optional_string() || "pending" - - %{ - id: - run_summary - |> Map.get(:id) - |> run_summary_dom_token(), - runId: - run_summary - |> Map.get(:run_id) - |> normalize_optional_string() || "unknown", - workflowName: - run_summary - |> Map.get(:workflow_name) - |> normalize_optional_string() || "unknown_workflow", - status: status, - statusBadgeClass: run_status_badge_class(status), - governanceSummary: run_governance_summary(run_summary), - recencyLabel: run_recency_label(run_summary), - detailPath: run_detail_path(assigns, run_summary), - requiresAttention: run_requires_attention?(status), - terminal: run_terminal?(status) - } - end - - defp dashboard_runtime_evidence_widget(runtime_summary) do - status = - runtime_summary - |> Map.get(:status) - |> normalize_optional_string() || "unknown" - - %{ - id: - runtime_summary - |> Map.get(:id) - |> run_summary_dom_token(), - repoLabel: - runtime_summary - |> Map.get(:repo_label) - |> normalize_optional_string() || "Unknown repo", - status: status, - statusLabel: runtime_evidence_status_label(status), - statusBadgeClass: runtime_evidence_badge_class(status), - reviewRequired: Map.get(runtime_summary, :review_required, false), - summary: - runtime_summary - |> Map.get(:summary) - |> normalize_optional_string() || "Runtime posture status is unavailable.", - details: runtime_evidence_details(runtime_summary) - } - end - - defp runtime_evidence_widget_counts(%{} = summary) do - %{ - blocked: Map.get(summary, :blocked_count, 0), - degraded: Map.get(summary, :degraded_count, 0), - available: Map.get(summary, :available_count, 0) - } - end - - defp runtime_evidence_widget_counts(_summary), do: %{blocked: 0, degraded: 0, available: 0} - - defp dashboard_workbench_path(_assigns), do: ManagedRepoRoutes.workbench_path() - - defp dashboard_inventory_detail_path(assigns, project) do - InventorySurface.project_detail_path( - project, - dashboard_selection_path(assigns.onboarding_next_actions, :work, :overview) - ) - end - - defp current_dashboard_path(assigns) do - dashboard_selection_path( - Map.get(assigns, :onboarding_next_actions, @onboarding_next_actions), - Map.get(assigns, :selected_dashboard_subject, :work), - Map.get(assigns, :selected_dashboard_section, :overview) - ) - end - - defp dashboard_runtime_inventory_badge(runtime_rows, project) when is_list(runtime_rows) do - managed_repo_id = - project - |> Map.get(:managed_repo_id) - |> normalize_optional_string() - - runtime_rows - |> Enum.find(fn runtime_summary -> - runtime_summary - |> Map.get(:managed_repo_id) - |> normalize_optional_string() == managed_repo_id - end) - |> case do - %{review_required: true} = runtime_summary -> - %{ - label: "Runtime: review required", - class: runtime_evidence_badge_class(Map.get(runtime_summary, :status)) - } - - %{} = runtime_summary -> - %{ - label: "Runtime: #{runtime_evidence_status_label(Map.get(runtime_summary, :status))}", - class: runtime_evidence_badge_class(Map.get(runtime_summary, :status)) - } - - _other -> - nil - end - end - - defp dashboard_runtime_inventory_badge(_runtime_rows, _project), do: nil - - defp dashboard_inventory_activity_label(project) do - case Map.get(project, :recent_activity_at) do - %DateTime{} = recent_activity_at -> - "Recent activity #{relative_time_label(recent_activity_at)}" - - _other -> - "Recent activity timing is unavailable." - end - end - - defp find_project_row(rows, project_id), do: InventoryActionState.find_row(rows, project_id) - - defp put_fix_workflow_kickoff_state( - socket, - project_id, - context_item_type, - kickoff_result, - return_to - ) do - update(socket, :fix_workflow_kickoff_states, fn states -> - InventoryActionState.put_fix_feedback( - states, - project_id, - context_item_type, - kickoff_result, - return_to - ) - end) - end - - defp put_issue_triage_workflow_kickoff_state( - socket, - project_id, - context_item_type, - kickoff_result, - return_to - ) do - update(socket, :issue_triage_workflow_kickoff_states, fn states -> - InventoryActionState.put_issue_triage_feedback( - states, - project_id, - context_item_type, - kickoff_result, - return_to - ) - end) - end - - defp put_recent_run_outcome_from_kickoff(socket, project_id, kickoff_result, return_to) do - update(socket, :recent_run_outcomes, fn outcomes -> - InventoryActionState.put_recent_run_outcome(outcomes, project_id, kickoff_result, return_to) - end) - end - - defp initiating_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - %{ - id: - user - |> Map.get(:id) - |> normalize_optional_string() || "unknown", - email: - user - |> Map.get(:email) - |> normalize_optional_string() - } - - _other -> - %{id: "unknown", email: nil} - end - end - - defp summarize_runtime_evidence(summaries) when is_list(summaries) do - %{ - total_count: length(summaries), - blocked_count: Enum.count(summaries, &(&1.status == "blocked")), - degraded_count: Enum.count(summaries, &(&1.status == "degraded")), - available_count: Enum.count(summaries, &(&1.status == "available")) - } - end - - defp humanize_runtime_value(value) do - value - |> normalize_optional_string() - |> case do - nil -> "unknown" - normalized -> normalized |> String.replace("_", " ") - end - end - - defp relative_time_label(%DateTime{} = datetime) do - seconds = DateTime.diff(DateTime.utc_now(), datetime, :second) - - cond do - seconds < 0 -> - "in the future" - - seconds < 60 -> - "just now" - - seconds < 3_600 -> - "#{div(seconds, 60)}m ago" - - seconds < 86_400 -> - "#{div(seconds, 3_600)}h ago" - - true -> - "#{div(seconds, 86_400)}d ago" - end - end - - defp run_detail_path(assigns, run_summary) do - project_id = - run_summary - |> Map.get(:project_id) - |> normalize_optional_string() - - run_id = - run_summary - |> Map.get(:run_id) - |> normalize_optional_string() - - if project_id && run_id do - ManagedRepoRoutes.run_detail_path(project_id, run_id, return_to: current_dashboard_path(assigns)) - else - ~p"/dashboard" - end - end - - defp run_requires_attention?(status), do: status in ["awaiting_approval", "cancelled", "failed"] - - defp run_terminal?(status), do: status in ["cancelled", "completed", "failed"] - - defp normalize_non_negative_integer(value) when is_integer(value) and value >= 0, do: value - - defp normalize_non_negative_integer(value) when is_binary(value) do - case Integer.parse(value) do - {count, ""} when count >= 0 -> count - _other -> 0 - end - end - - defp normalize_non_negative_integer(_value), do: 0 - - defp maybe_put_query_param(params, _key, nil), do: params - defp maybe_put_query_param(params, key, value), do: Keyword.put(params, key, value) - - defp pluralize(1, singular, _plural), do: singular - defp pluralize(_count, _singular, plural), do: plural - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default - - defp normalize_optional_string(nil), do: nil - defp normalize_optional_string(value) when is_boolean(value), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized_value -> normalized_value - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_optional_string(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_optional_string(_value), do: nil + use JidoCodeWeb.ResetLive, title: "Dashboard" end diff --git a/lib/jido_code_web/live/decision_detail_live.ex b/lib/jido_code_web/live/decision_detail_live.ex index a380640c..3f8021bb 100644 --- a/lib/jido_code_web/live/decision_detail_live.ex +++ b/lib/jido_code_web/live/decision_detail_live.ex @@ -1,679 +1,3 @@ defmodule JidoCodeWeb.DecisionDetailLive do - # covers: architecture.memory_graph_workflow_and_operator_expansion.governed_surfaces_host_memory_context - # covers: architecture.memory_graph_workflow_and_operator_expansion.operator_memory_actions_use_product_owned_boundaries - # covers: architecture.memory_graph_workflow_and_operator_expansion.cross_graph_navigation_connects_memory_code_and_governed_history - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_actions_preserve_freshness_supersession_and_provenance - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_promotions_create_governed_follow_up - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.operator_memory_actions_are_available_from_canonical_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_and_governed_surfaces_host_bounded_memory_context - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.canonical_routes_remain_product_and_governed - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.cross_graph_navigation_stays_consistent_across_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.memory_aware_workflow_and_governed_follow_up_use_product_projections - use JidoCodeWeb, :live_view - - import JidoCodeWeb.GovernedMemoryHelpers - - alias JidoCode.Control.{Actor, RepoBridge} - alias JidoCode.Governance.{Decision, Evidence} - alias JidoCode.MemoryGraph.{FollowUpSurface, GovernedSurfaceContext, OperatorService, ProductService, SurfaceFeedback} - alias JidoCode.Operations.WorkItem - alias JidoCode.Orchestration.Run - - @surface_label "this governed decision surface" - - @impl true - def mount(%{"id" => project_id, "decision_id" => decision_id}, _session, socket) do - socket = - socket - |> assign(:project_id, project_id) - |> assign(:memory_action_feedback, nil) - |> load_decision_assigns(project_id, decision_id) - - {:ok, socket} - end - - @impl true - def handle_event("recover_memory_graph", _params, socket) do - with %{managed_repo_id: managed_repo_id, workspace_path: workspace_path} <- socket.assigns.memory_context, - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id, - workspace_path when is_binary(workspace_path) <- workspace_path do - case ProductService.recover(managed_repo_id, workspace_path) do - {:ok, _result} -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_result( - memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason, diagnostics} -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - reason, - diagnostics, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - end - else - _other -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - :memory_governed_scope_unavailable, - nil, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - end - end - - @impl true - def handle_event( - "validate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.validate(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, _result} -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :validate, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("validate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "invalidate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.invalidate(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, _result} -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :invalidate, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("invalidate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "promote_memory_follow_up", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.promote_follow_up(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, %{result: %{work_item: work_item}, target: :work_item}} -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :promote_follow_up, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label, - work_item_id: work_item.id - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("promote_memory_follow_up", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "supersede_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}, decision: %Decision{} = decision}} = socket - ) do - case OperatorService.supersede_with_governed_decision( - projection, - memory_iri, - decision, - memory_operator_opts(socket, decision_id: decision.id) - ) do - {:ok, _result} -> - refreshed_socket = refresh_decision_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :supersede_with_governed_decision, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("supersede_memory", _params, socket), do: {:noreply, socket} - - @impl true - def render(assigns) do - ~H""" - -
-
-
-

- Governed decision -

-

- {display_string(@decision.decision_key, "Decision detail")} -

-

- Decision not found -

-
- -
- <.link - :if={run_route(@route_repo_id, @run && @run.run_id)} - navigate={run_route(@route_repo_id, @run && @run.run_id)} - class="link link-primary text-sm" - > - Open related run - - <.link - :if={repo_route(@route_repo_id)} - navigate={repo_route(@route_repo_id)} - class="link link-primary text-sm" - > - Back to repository - -
-
- - <%= if @decision do %> -
-
-

ID

-

{@decision.id}

-
- -
-

Decision key

-

{display_string(@decision.decision_key)}

-
- -
-

Outcome

-

{display_atom(@decision.decision)}

-
- -
-

Run

-
-

- {display_string(@run && @run.run_id, "Unavailable")} -

- <.link - :if={run_route(@route_repo_id, @run && @run.run_id)} - id="decision-detail-run-route" - navigate={run_route(@route_repo_id, @run && @run.run_id)} - class="link link-primary text-xs" - > - Open run - -
-
- -
-

Work item

-
-

- {display_string(@work_item && @work_item.id, "Unattached")} -

- <.link - :if={work_item_route(@route_repo_id, @work_item && @work_item.id)} - id="decision-detail-work-item-route" - navigate={work_item_route(@route_repo_id, @work_item && @work_item.id)} - class="link link-primary text-xs" - > - Open work item - -
-
- -
-

Rationale

-

- {display_string(@decision.rationale, "No rationale recorded.")} -

-
-
- -
-
-

Related evidence

- -
- - <%= if @related_evidence == [] do %> - - <% else %> - - <% end %> -
- -
-
-

Decision memory context

-

- Memory state: {Map.get(@memory_context.graph, :state, :unavailable)} -

- <.operator_state_notice - :if={@memory_action_feedback} - id="decision-detail-memory-action-feedback" - title="Decision memory update" - state={@memory_action_feedback} - kind={memory_feedback_kind(@memory_action_feedback)} - compact={true} - /> - <.memory_status_notice - :if={@memory_context.notice} - id="decision-detail-memory-context-notice" - title="Decision memory status" - state={@memory_context.notice} - kind={Map.get(@memory_context, :notice_kind, :warning)} - recovery={Map.get(@memory_context, :recovery)} - recover_event="recover_memory_graph" - recover_id="decision-detail-memory-recover" - /> -
- - <%= if @memory_context.surface do %> -
-
-

- {@memory_context.surface.label} -

-

- Memory: {@memory_context.surface.memory_count} | Provenance: {@memory_context.surface.provenance_count} -

-
- -
-

- Memory-aware follow-up -

-

- {@memory_follow_up_preview.summary} -

-

- Recommended action: {@memory_follow_up_preview.recommended_action_label} | Priority: {@memory_follow_up_preview.priority} | Urgency: {@memory_follow_up_preview.urgency} -

-

- Selected memory kinds: {Enum.join(@memory_follow_up_preview.memory_kinds, ", ")} -

- <.link - :if={@memory_follow_up_preview.route} - id="decision-detail-memory-follow-up-preview-route" - navigate={@memory_follow_up_preview.route} - class="link link-primary text-xs" - > - {@memory_follow_up_preview.route_label} - -
- -
-

- Durable memories -

- - <%= if @memory_context.surface.memories.items == [] do %> -

- No durable memories currently point at this decision. -

- <% else %> -
    -
  1. -

    - {memory_item_kind(item)}: {memory_item_content(item)} -

    -

    - Freshness: {memory_item_freshness(item)} | Decision status: {memory_item_decision_status(item)} -

    -
    - - - -
    - <.memory_link_groups dom_prefix={"decision-detail-memory-#{index}"} item={item} /> -
  2. -
- <% end %> -
- - - -
-

- Workflow provenance -

- - <%= if @memory_context.surface.provenance.items == [] do %> -

- No workflow provenance currently points at this decision. -

- <% else %> -
    -
  1. -

    - {provenance_item_kind(item)}: {provenance_item_label(item)} -

    -

    - Revision: {provenance_item_revision(item)} -

    - <.memory_link_groups dom_prefix={"decision-detail-memory-provenance-#{index}"} item={item} /> -
  2. -
- <% end %> -
-
- <% end %> -
- <% else %> -
-

- No governed decision with id {@decision_id} - is available on this managed-repository route. -

-
- <% end %> -
-
- """ - end - - defp load_decision_assigns(socket, project_id, decision_id) do - case load_decision_state(project_id, decision_id) do - {:ok, state} -> assign_decision_state(socket, state) - {:error, :not_found} -> assign_missing_decision(socket, project_id, decision_id) - end - end - - defp refresh_decision_assigns(socket) do - load_decision_assigns(socket, socket.assigns.project_id, socket.assigns.decision_id) - end - - defp load_decision_state(project_id, decision_id) do - with {:ok, scope} <- RepoBridge.repo_scope(project_id), - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id(scope), - {:ok, [%Decision{} = decision]} <- - Decision.read( - query: [filter: [id: decision_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - route_repo_id = route_repo_id(scope) || managed_repo_id - run = load_related_run(managed_repo_id, decision.run_id) - work_item = load_related_work_item(managed_repo_id, decision.work_item_id) - - memory_context = - GovernedSurfaceContext.load_governed_detail( - scope, - :decision, - decision, - managed_repo_id: managed_repo_id, - workspace_path: load_repo_workspace_path(scope), - current_route: decision_route(route_repo_id, decision.id) - ) - - {:ok, - %{ - project_id: project_id, - scope: scope, - decision: decision, - run: run, - work_item: work_item, - related_evidence: load_related_evidence(managed_repo_id, decision), - memory_context: memory_context, - memory_follow_up_preview: load_memory_follow_up_preview(memory_context, route_repo_id, decision) - }} - else - _other -> {:error, :not_found} - end - end - - defp assign_decision_state(socket, state) do - socket - |> assign(:project_id, state.project_id) - |> assign(:decision, state.decision) - |> assign(:decision_id, state.decision.id) - |> assign(:managed_repo_id, managed_repo_id(state.scope)) - |> assign(:route_repo_id, route_repo_id(state.scope) || managed_repo_id(state.scope)) - |> assign(:run, state.run) - |> assign(:work_item, state.work_item) - |> assign(:related_evidence, state.related_evidence) - |> assign(:memory_context, state.memory_context) - |> assign(:memory_follow_up_preview, state.memory_follow_up_preview) - end - - defp assign_missing_decision(socket, project_id, decision_id) do - socket - |> assign(:project_id, project_id) - |> assign(:decision, nil) - |> assign(:decision_id, decision_id) - |> assign(:managed_repo_id, nil) - |> assign(:route_repo_id, normalize_optional_string(project_id)) - |> assign(:run, nil) - |> assign(:work_item, nil) - |> assign(:related_evidence, []) - |> assign(:memory_context, nil) - |> assign(:memory_follow_up_preview, nil) - end - - defp load_related_run(managed_repo_id, run_internal_id) do - case Run.read( - query: [filter: [id: run_internal_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - {:ok, [%Run{} = run]} -> run - _other -> nil - end - end - - defp load_related_work_item(_managed_repo_id, nil), do: nil - - defp load_related_work_item(managed_repo_id, work_item_id) do - case WorkItem.read( - query: [filter: [id: work_item_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - {:ok, [%WorkItem{} = work_item]} -> work_item - _other -> nil - end - end - - defp load_related_evidence(managed_repo_id, %Decision{} = decision) do - evidence_ids = Map.get(decision, :evidence_ids, []) - - case Evidence.read( - query: [filter: [managed_repo_id: managed_repo_id, run_id: decision.run_id], sort: [recorded_at: :asc]], - actor: Actor.operator_actor() - ) do - {:ok, evidence_records} -> - Enum.filter(evidence_records, &(&1.id in evidence_ids)) - - _other -> - [] - end - end - - defp load_memory_follow_up_preview(%{surface: %{memories: projection}}, route_repo_id, %Decision{} = decision) do - FollowUpSurface.preview( - projection, - route: decision_memory_route(route_repo_id, decision.id), - category: "governed_follow_up" - ) - end - - defp load_memory_follow_up_preview(_memory_context, _route_repo_id, _decision), do: nil - - defp assign_memory_action_error(socket, reason) do - refreshed_socket = refresh_decision_assigns(socket) - - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_error( - reason, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - ) - end - - defp memory_operator_opts(socket, extra_opts \\ []) do - memory_operator_base_opts( - socket, - run_id: socket.assigns.run && socket.assigns.run.run_id, - work_item_id: socket.assigns.work_item && socket.assigns.work_item.id, - decision_id: socket.assigns.decision && socket.assigns.decision.id - ) ++ extra_opts - end - - defp decision_memory_route(repo_id, decision_id) do - memory_anchor_route(decision_route(repo_id, decision_id), "#decision-detail-memory-context") - end + use JidoCodeWeb.ResetLive, title: "Decision Detail" end diff --git a/lib/jido_code_web/live/evidence_detail_live.ex b/lib/jido_code_web/live/evidence_detail_live.ex index f4823402..486a04a8 100644 --- a/lib/jido_code_web/live/evidence_detail_live.ex +++ b/lib/jido_code_web/live/evidence_detail_live.ex @@ -1,684 +1,3 @@ defmodule JidoCodeWeb.EvidenceDetailLive do - # covers: architecture.memory_graph_workflow_and_operator_expansion.governed_surfaces_host_memory_context - # covers: architecture.memory_graph_workflow_and_operator_expansion.operator_memory_actions_use_product_owned_boundaries - # covers: architecture.memory_graph_workflow_and_operator_expansion.cross_graph_navigation_connects_memory_code_and_governed_history - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_actions_preserve_freshness_supersession_and_provenance - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_promotions_create_governed_follow_up - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.operator_memory_actions_are_available_from_canonical_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_and_governed_surfaces_host_bounded_memory_context - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.canonical_routes_remain_product_and_governed - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.cross_graph_navigation_stays_consistent_across_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.memory_aware_workflow_and_governed_follow_up_use_product_projections - use JidoCodeWeb, :live_view - - import JidoCodeWeb.GovernedMemoryHelpers - - alias JidoCode.Control.{Actor, RepoBridge} - alias JidoCode.Governance.{Decision, Evidence} - alias JidoCode.MemoryGraph.{FollowUpSurface, GovernedSurfaceContext, OperatorService, ProductService, SurfaceFeedback} - alias JidoCode.Operations.WorkItem - alias JidoCode.Orchestration.Run - - @surface_label "this governed evidence surface" - - @impl true - def mount(%{"id" => project_id, "evidence_id" => evidence_id}, _session, socket) do - socket = - socket - |> assign(:project_id, project_id) - |> assign(:memory_action_feedback, nil) - |> load_evidence_assigns(project_id, evidence_id) - - {:ok, socket} - end - - @impl true - def handle_event("recover_memory_graph", _params, socket) do - with %{managed_repo_id: managed_repo_id, workspace_path: workspace_path} <- socket.assigns.memory_context, - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id, - workspace_path when is_binary(workspace_path) <- workspace_path do - case ProductService.recover(managed_repo_id, workspace_path) do - {:ok, _result} -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_result( - memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason, diagnostics} -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - reason, - diagnostics, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - end - else - _other -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - :memory_governed_scope_unavailable, - nil, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - end - end - - @impl true - def handle_event( - "validate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.validate(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, _result} -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :validate, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("validate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "invalidate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.invalidate(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, _result} -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :invalidate, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("invalidate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "promote_memory_follow_up", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.promote_follow_up(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, %{result: %{work_item: work_item}, target: :work_item}} -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :promote_follow_up, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label, - work_item_id: work_item.id - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("promote_memory_follow_up", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "supersede_memory", - %{"memory_iri" => memory_iri, "decision_id" => decision_id}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}, related_decisions: decisions}} = socket - ) do - case Enum.find(decisions, &(normalize_optional_string(map_get(&1, :id, "id")) == decision_id)) do - %Decision{} = decision -> - case OperatorService.supersede_with_governed_decision( - projection, - memory_iri, - decision, - memory_operator_opts(socket, decision_id: decision_id) - ) do - {:ok, _result} -> - refreshed_socket = refresh_evidence_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :supersede_with_governed_decision, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - - _other -> - {:noreply, assign_memory_action_error(socket, :governed_decision_not_found)} - end - end - - @impl true - def handle_event("supersede_memory", _params, socket), do: {:noreply, socket} - - @impl true - def render(assigns) do - ~H""" - -
-
-
-

- Governed evidence -

-

- {display_string(@evidence.summary, "Evidence detail")} -

-

- Evidence not found -

-
- -
- <.link - :if={run_route(@route_repo_id, @run && @run.run_id)} - navigate={run_route(@route_repo_id, @run && @run.run_id)} - class="link link-primary text-sm" - > - Open related run - - <.link - :if={repo_route(@route_repo_id)} - navigate={repo_route(@route_repo_id)} - class="link link-primary text-sm" - > - Back to repository - -
-
- - <%= if @evidence do %> -
-
-

ID

-

{@evidence.id}

-
- -
-

Key

-

{display_string(@evidence.key)}

-
- -
-

Type

-

{display_string(@evidence.evidence_type)}

-
- -
-

Source

-

{display_string(@evidence.source)}

-
- -
-

Summary

-

{display_string(@evidence.summary)}

-
- -
-

Run

-
-

- {display_string(@run && @run.run_id, "Unavailable")} -

- <.link - :if={run_route(@route_repo_id, @run && @run.run_id)} - id="evidence-detail-run-route" - navigate={run_route(@route_repo_id, @run && @run.run_id)} - class="link link-primary text-xs" - > - Open run - -
-
- -
-

Work item

-
-

- {display_string(@work_item && @work_item.id, "Unattached")} -

- <.link - :if={work_item_route(@route_repo_id, @work_item && @work_item.id)} - id="evidence-detail-work-item-route" - navigate={work_item_route(@route_repo_id, @work_item && @work_item.id)} - class="link link-primary text-xs" - > - Open work item - -
-
-
- -
-
-

Related decisions

- -
- - <%= if @related_decisions == [] do %> - - <% else %> - - <% end %> -
- -
-
-

Evidence memory context

-

- Memory state: {Map.get(@memory_context.graph, :state, :unavailable)} -

- <.operator_state_notice - :if={@memory_action_feedback} - id="evidence-detail-memory-action-feedback" - title="Evidence memory update" - state={@memory_action_feedback} - kind={memory_feedback_kind(@memory_action_feedback)} - compact={true} - /> - <.memory_status_notice - :if={@memory_context.notice} - id="evidence-detail-memory-context-notice" - title="Evidence memory status" - state={@memory_context.notice} - kind={Map.get(@memory_context, :notice_kind, :warning)} - recovery={Map.get(@memory_context, :recovery)} - recover_event="recover_memory_graph" - recover_id="evidence-detail-memory-recover" - /> -
- - <%= if @memory_context.surface do %> -
-
-

- {@memory_context.surface.label} -

-

- Memory: {@memory_context.surface.memory_count} | Provenance: {@memory_context.surface.provenance_count} -

-
- -
-

- Memory-aware follow-up -

-

- {@memory_follow_up_preview.summary} -

-

- Recommended action: {@memory_follow_up_preview.recommended_action_label} | Priority: {@memory_follow_up_preview.priority} | Urgency: {@memory_follow_up_preview.urgency} -

-

- Selected memory kinds: {Enum.join(@memory_follow_up_preview.memory_kinds, ", ")} -

- <.link - :if={@memory_follow_up_preview.route} - id="evidence-detail-memory-follow-up-preview-route" - navigate={@memory_follow_up_preview.route} - class="link link-primary text-xs" - > - {@memory_follow_up_preview.route_label} - -
- -
-

- Durable memories -

- - <%= if @memory_context.surface.memories.items == [] do %> -

- No durable memories currently point at this evidence record. -

- <% else %> -
    -
  1. -

    - {memory_item_kind(item)}: {memory_item_content(item)} -

    -

    - Freshness: {memory_item_freshness(item)} | Decision status: {memory_item_decision_status(item)} -

    -
    - - - -
    - <.memory_link_groups dom_prefix={"evidence-detail-memory-#{index}"} item={item} /> -
  2. -
- <% end %> -
- - - -
-

- Workflow provenance -

- - <%= if @memory_context.surface.provenance.items == [] do %> -

- No workflow provenance currently points at this evidence record. -

- <% else %> -
    -
  1. -

    - {provenance_item_kind(item)}: {provenance_item_label(item)} -

    -

    - Revision: {provenance_item_revision(item)} -

    - <.memory_link_groups dom_prefix={"evidence-detail-memory-provenance-#{index}"} item={item} /> -
  2. -
- <% end %> -
-
- <% end %> -
- <% else %> -
-

- No governed evidence with id {@evidence_id} - is available on this managed-repository route. -

-
- <% end %> -
-
- """ - end - - defp load_evidence_assigns(socket, project_id, evidence_id) do - case load_evidence_state(project_id, evidence_id) do - {:ok, state} -> assign_evidence_state(socket, state) - {:error, :not_found} -> assign_missing_evidence(socket, project_id, evidence_id) - end - end - - defp refresh_evidence_assigns(socket) do - load_evidence_assigns(socket, socket.assigns.project_id, socket.assigns.evidence_id) - end - - defp load_evidence_state(project_id, evidence_id) do - with {:ok, scope} <- RepoBridge.repo_scope(project_id), - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id(scope), - {:ok, [%Evidence{} = evidence]} <- - Evidence.read( - query: [filter: [id: evidence_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - route_repo_id = route_repo_id(scope) || managed_repo_id - run = load_related_run(managed_repo_id, evidence.run_id) - work_item = load_related_work_item(managed_repo_id, evidence.work_item_id) - - memory_context = - GovernedSurfaceContext.load_governed_detail( - scope, - :evidence, - evidence, - managed_repo_id: managed_repo_id, - workspace_path: load_repo_workspace_path(scope), - current_route: evidence_route(route_repo_id, evidence.id) - ) - - {:ok, - %{ - project_id: project_id, - scope: scope, - evidence: evidence, - run: run, - work_item: work_item, - related_decisions: load_related_decisions(managed_repo_id, evidence.run_id), - memory_context: memory_context, - memory_follow_up_preview: load_memory_follow_up_preview(memory_context, route_repo_id, evidence) - }} - else - _other -> {:error, :not_found} - end - end - - defp assign_evidence_state(socket, state) do - socket - |> assign(:project_id, state.project_id) - |> assign(:evidence, state.evidence) - |> assign(:evidence_id, state.evidence.id) - |> assign(:managed_repo_id, managed_repo_id(state.scope)) - |> assign(:route_repo_id, route_repo_id(state.scope) || managed_repo_id(state.scope)) - |> assign(:run, state.run) - |> assign(:work_item, state.work_item) - |> assign(:related_decisions, state.related_decisions) - |> assign(:memory_context, state.memory_context) - |> assign(:memory_follow_up_preview, state.memory_follow_up_preview) - end - - defp assign_missing_evidence(socket, project_id, evidence_id) do - socket - |> assign(:project_id, project_id) - |> assign(:evidence, nil) - |> assign(:evidence_id, evidence_id) - |> assign(:managed_repo_id, nil) - |> assign(:route_repo_id, normalize_optional_string(project_id)) - |> assign(:run, nil) - |> assign(:work_item, nil) - |> assign(:related_decisions, []) - |> assign(:memory_context, nil) - |> assign(:memory_follow_up_preview, nil) - end - - defp load_related_run(managed_repo_id, run_internal_id) do - case Run.read( - query: [filter: [id: run_internal_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - {:ok, [%Run{} = run]} -> run - _other -> nil - end - end - - defp load_related_work_item(_managed_repo_id, nil), do: nil - - defp load_related_work_item(managed_repo_id, work_item_id) do - case WorkItem.read( - query: [filter: [id: work_item_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - {:ok, [%WorkItem{} = work_item]} -> work_item - _other -> nil - end - end - - defp load_related_decisions(managed_repo_id, run_internal_id) do - case Decision.read( - query: [filter: [managed_repo_id: managed_repo_id, run_id: run_internal_id], sort: [decided_at: :desc]], - actor: Actor.operator_actor() - ) do - {:ok, decisions} -> decisions - _other -> [] - end - end - - defp load_memory_follow_up_preview(%{surface: %{memories: projection}}, route_repo_id, %Evidence{} = evidence) do - FollowUpSurface.preview( - projection, - route: evidence_memory_route(route_repo_id, evidence.id), - category: "governed_follow_up" - ) - end - - defp load_memory_follow_up_preview(_memory_context, _route_repo_id, _evidence), do: nil - - defp assign_memory_action_error(socket, reason) do - refreshed_socket = refresh_evidence_assigns(socket) - - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_error( - reason, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - ) - end - - defp memory_operator_opts(socket, extra_opts \\ []) do - memory_operator_base_opts( - socket, - run_id: socket.assigns.run && socket.assigns.run.run_id, - work_item_id: socket.assigns.work_item && socket.assigns.work_item.id, - evidence_id: socket.assigns.evidence && socket.assigns.evidence.id - ) ++ extra_opts - end - - defp evidence_memory_route(repo_id, evidence_id) do - memory_anchor_route(evidence_route(repo_id, evidence_id), "#evidence-detail-memory-context") - end + use JidoCodeWeb.ResetLive, title: "Evidence Detail" end diff --git a/lib/jido_code_web/live/home_live.ex b/lib/jido_code_web/live/home_live.ex index 86327f42..b8034b93 100644 --- a/lib/jido_code_web/live/home_live.ex +++ b/lib/jido_code_web/live/home_live.ex @@ -1,613 +1,3 @@ defmodule JidoCodeWeb.HomeLive do - # covers: baseline.surface.auth_entrypoints_visible - # covers: baseline.surface.welcome_surface_consolidated - # covers: users.admin_system.bootstrap_admin - # covers: users.admin_system.registration_guardrails - # covers: setup.onboarding.deployment_mode_auto_detected - # covers: auth.provider_login_flow.entrypoint_visible - # covers: auth.provider_login_flow.local_auth_fallback_visible - # covers: auth.operator_settings.hidden_during_bootstrap_entry - # covers: auth.self_hosted_provider_integration.login_and_service_ready - # covers: auth.self_hosted_provider_integration.service_independent_of_login_toggle - # covers: auth.self_hosted_provider_integration.local_auth_fallback_on_broker_failure - # covers: auth.self_hosted_provider_integration.allowlist_rejection_without_service_regression - # covers: auth.self_hosted_provider_integration.bootstrap_precedes_provider_login - use JidoCodeWeb, :live_view - - require Ash.Query - - alias AshAuthentication.{Info, Strategy} - alias JidoCode.Accounts.User - alias JidoCode.AuthProviders - alias JidoCode.AuthProviders.ProviderConfig - alias JidoCode.Setup.BootstrapStatus - alias JidoCode.Setup.DeploymentMode - alias JidoCode.Setup.OwnerBootstrap - alias JidoCode.Setup.OwnerRecovery - alias JidoCode.Setup.PrerequisiteChecks - alias JidoCode.Setup.SystemConfig - - @impl true - def mount(_params, _session, socket) do - bootstrap_status = BootstrapStatus.current() - owner_status = resolve_owner_status(bootstrap_status) - - if socket.assigns[:current_user] && bootstrap_status.state == :continue_setup do - {:ok, push_navigate(socket, to: ~p"/setup")} - else - socket = - socket - |> assign(:bootstrap_status, bootstrap_status) - |> assign(:deployment_mode, DeploymentMode.current()) - |> assign(:prereq_status, initial_prereq_status(bootstrap_status)) - |> assign(:prereq_report, nil) - |> assign(:owner_mode, owner_status.mode) - |> assign(:owner_email, owner_status.owner_email) - |> assign(:owner_form, build_owner_form(owner_status.owner_email)) - |> assign(:owner_recovery_form, build_recovery_form(owner_status.owner_email)) - |> assign(:save_error, owner_status.error || bootstrap_status.diagnostic) - |> assign( - github_login_path: github_login_path(), - github_login_enabled?: github_login_enabled?() - ) - - if connected?(socket) && bootstrap_status.state == :bootstrap_required do - send(self(), :run_prereqs) - end - - {:ok, socket} - end - end - - @impl true - def handle_event("recheck_prereqs", _params, socket) do - send(self(), :run_prereqs) - - {:noreply, - socket - |> assign(:prereq_status, :checking) - |> assign(:prereq_report, nil)} - end - - def handle_event("bootstrap_owner", %{"owner" => owner_params}, socket) do - cond do - socket.assigns.bootstrap_status.state == :bootstrap_required and socket.assigns.prereq_status != :pass -> - {:noreply, assign(socket, :save_error, "System checks must pass before creating an account.")} - - socket.assigns.bootstrap_status.state not in [:bootstrap_required, :continue_setup] -> - {:noreply, assign(socket, :save_error, "Bootstrap is unavailable from the current public state.")} - - true -> - case OwnerBootstrap.bootstrap(owner_params) do - {:ok, result} -> - prerequisite_report = current_prerequisite_report(socket) - - case persist_bootstrap_progress(socket.assigns.bootstrap_status, prerequisite_report, result) do - {:ok, _config} -> - {:noreply, - socket - |> assign(:save_error, nil) - |> redirect(to: owner_sign_in_with_token_path(result.token))} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - - {:error, {_error_type, diagnostic}} -> - owner_status = resolve_owner_status(BootstrapStatus.current()) - - {:noreply, - socket - |> assign(:bootstrap_status, BootstrapStatus.current()) - |> assign(:owner_mode, owner_status.mode) - |> assign(:owner_email, owner_status.owner_email) - |> assign(:save_error, diagnostic) - |> assign(:owner_form, build_owner_form(owner_params["email"] || owner_status.owner_email))} - end - end - end - - def handle_event("recover_owner", %{"owner_recovery" => recovery_params}, socket) do - if socket.assigns.bootstrap_status.state == :continue_setup and socket.assigns.owner_mode == :confirm do - case OwnerRecovery.recover(recovery_params) do - {:ok, result} -> - prerequisite_report = current_prerequisite_report(socket) - - case persist_recovery_progress(socket.assigns.bootstrap_status, prerequisite_report, result) do - {:ok, _config} -> - {:noreply, - socket - |> assign(:save_error, nil) - |> redirect(to: owner_sign_in_with_token_path(result.token))} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - - {:error, {_error_type, diagnostic}} -> - owner_status = resolve_owner_status(BootstrapStatus.current()) - - {:noreply, - socket - |> assign(:bootstrap_status, BootstrapStatus.current()) - |> assign(:owner_mode, owner_status.mode) - |> assign(:owner_email, owner_status.owner_email) - |> assign(:save_error, diagnostic) - |> assign( - :owner_recovery_form, - build_recovery_form(recovery_params["email"] || owner_status.owner_email, recovery_params) - )} - end - else - {:noreply, assign(socket, :save_error, "Owner recovery is only available while setup is still incomplete.")} - end - end - - @impl true - def handle_info(:run_prereqs, socket) do - report = PrerequisiteChecks.run() - - {:noreply, - socket - |> assign(:prereq_status, report.status) - |> assign(:prereq_report, report)} - end - - @impl true - def render(assigns) do - ~H""" -
-
-
- <%= case @bootstrap_status.state do %> - <% :bootstrap_required -> %> -
-
-

- First-Run Setup -

-

Create your admin account

-

- {bootstrap_intro_copy(@deployment_mode)} -

-
- -
-
- <.icon name="hero-arrow-path" class="size-5 animate-spin" /> - Checking your system… -
- -
- - <.icon name="hero-check-circle-mini" class="size-4" /> System ready - -
- -
-
- <.icon name="hero-exclamation-triangle-mini" class="size-5" /> - {prerequisite_banner_message(@prereq_status)} -
- -
- - Show technical details - -
    -
  • - - {prereq_status_label(check.status)} - -
    - {check.name} - {[" — ", check.detail]} -

    - {check.remediation} -

    -
    -
  • -
-
- -
- -
-
-
- -
- <.icon name="hero-x-circle-mini" class="size-5" /> - {@save_error} -
- -
-
- <.icon name="hero-lock-closed-mini" class="size-4 inline" /> Complete system check first -
- -
- <.form for={@owner_form} id="welcome-owner-form" phx-submit="bootstrap_owner" class="space-y-4"> - <.input field={@owner_form[:email]} type="email" label="Email" required autocomplete="email" /> -
- <.input - field={@owner_form[:password]} - type="password" - label="Password" - minlength="8" - required - autocomplete="new-password" - /> -

Minimum 8 characters

-
- <.input - field={@owner_form[:password_confirmation]} - type="password" - label="Confirm password" - minlength="8" - required - autocomplete="new-password" - /> - - -
-
-
- <% :continue_setup -> %> -
-
-

- Continue Setup -

-

Sign in to finish onboarding

-

- Use the existing admin account to continue setup. Public account creation is closed after the first install. -

-
- -
- Existing admin: {@owner_email || "Unavailable"} -
- -
- <.icon name="hero-x-circle-mini" class="size-5" /> - {@save_error} -
- - <.form for={@owner_form} id="continue-setup-owner-form" phx-submit="bootstrap_owner" class="space-y-4"> - <.input field={@owner_form[:email]} type="email" label="Email" required autocomplete="email" /> - <.input - field={@owner_form[:password]} - type="password" - label="Admin password" - required - autocomplete="current-password" - /> - - - -
-

Need to recover access?

-

- Recovery is only for the existing bootstrap admin and requires explicit verification before credentials are changed. -

- - <.form - for={@owner_recovery_form} - id="continue-setup-recovery-form" - phx-submit="recover_owner" - class="mt-4 space-y-4" - > - <.input field={@owner_recovery_form[:email]} type="email" label="Admin email" required /> - <.input - field={@owner_recovery_form[:password]} - type="password" - label="New password" - required - minlength="8" - /> - <.input - field={@owner_recovery_form[:password_confirmation]} - type="password" - label="Confirm new password" - required - minlength="8" - /> - <.input - field={@owner_recovery_form[:verification_phrase]} - type="text" - label="Verification phrase" - required - value={OwnerRecovery.verification_phrase()} - /> - <.input - field={@owner_recovery_form[:verification_ack]} - type="checkbox" - label="I understand this resets the existing bootstrap admin credentials" - /> - - -
-
- <% :invalid_state -> %> -
-
-

- Bootstrap Repair Needed -

-

The install needs attention

-

- The public bootstrap flow is paused because the local account state is inconsistent. -

-
- -
- <.icon name="hero-x-circle-mini" class="size-5" /> - {@save_error || "Repair the bootstrap state before continuing."} -
-
- <% :ready -> %> -
-
- <%!-- covers: baseline.surface.welcome_landing_copy --%> -

- {if @current_user, do: "Ready", else: "Bootstrap Complete"} -

-

Welcome to Jido Code

-

- {if(@current_user, - do: ready_signed_in_intro_copy(@deployment_mode), - else: ready_intro_copy(@deployment_mode) - )} -

-
- -
- <%= if @current_user do %> -
-

Signed In

-

{@current_user.email}

-

- Bootstrap is complete. Use dashboard for normal product work, and use settings when you need to manage provider login or deployment-local Git automation. -

-
- -
- <.link id="welcome-open-dashboard" navigate={~p"/dashboard"} class="btn btn-primary btn-block"> - Open Dashboard - - <.link id="welcome-open-settings" navigate={~p"/settings/auth"} class="btn btn-outline btn-block"> - Open Auth & Integrations - -
- -
- Sign Out -
- -
- Local email auth remains the fallback path even when hosted provider sign-in is enabled. Dashboard is the default authenticated entry, and Settings owns durable provider and Git integration management. -
- <% else %> -
- Sign In -
- - <%= if @github_login_enabled? do %> - - <% end %> - <% end %> -
-
- <% end %> -
-
-
- """ - end - - defp resolve_owner_status(%{state: state}) when state in [:bootstrap_required, :continue_setup] do - case OwnerBootstrap.status() do - {:ok, %{mode: :create}} -> - %{mode: :create, owner_email: nil, error: nil} - - {:ok, %{mode: :confirm, owner: owner}} -> - %{mode: :confirm, owner_email: to_string(owner.email), error: nil} - - {:error, {_error_type, diagnostic}} -> - %{mode: :error, owner_email: nil, error: diagnostic} - end - end - - defp resolve_owner_status(_status), do: %{mode: :inactive, owner_email: nil, error: nil} - - defp initial_prereq_status(%{state: :bootstrap_required}), do: :checking - defp initial_prereq_status(_status), do: :idle - - defp current_prerequisite_report(socket) do - socket.assigns[:prereq_report] || PrerequisiteChecks.run() - end - - defp persist_bootstrap_progress(%{config: %SystemConfig{} = config}, prerequisite_report, result) do - cond do - config.onboarding_step == 1 -> - with {:ok, _config} <- SystemConfig.save_step_progress(bootstrap_step_1_state(prerequisite_report)), - {:ok, saved_config} <- SystemConfig.save_step_progress(bootstrap_step_2_state(result)) do - {:ok, saved_config} - end - - config.onboarding_step == 2 -> - SystemConfig.save_step_progress(bootstrap_step_2_state(result)) - - true -> - {:ok, config} - end - end - - defp persist_recovery_progress(%{config: %SystemConfig{} = config}, prerequisite_report, result) do - cond do - config.onboarding_step == 1 -> - with {:ok, _config} <- SystemConfig.save_step_progress(bootstrap_step_1_state(prerequisite_report)), - {:ok, saved_config} <- SystemConfig.save_step_progress(recovery_step_2_state(result)) do - {:ok, saved_config} - end - - config.onboarding_step == 2 -> - SystemConfig.save_step_progress(recovery_step_2_state(result)) - - true -> - {:ok, config} - end - end - - defp bootstrap_step_1_state(prerequisite_report) do - %{ - "validated_note" => "System prerequisites verified (welcome flow).", - "prerequisite_checks" => PrerequisiteChecks.serialize_for_state(prerequisite_report) - } - end - - defp bootstrap_step_2_state(result) do - %{ - "validated_note" => result.validated_note, - "owner_email" => to_string(result.owner.email), - "owner_mode" => Atom.to_string(result.owner_mode), - "registration_actions_disabled" => true - } - end - - defp recovery_step_2_state(result) do - %{ - "validated_note" => result.validated_note, - "owner_email" => to_string(result.owner.email), - "owner_mode" => Atom.to_string(result.owner_mode), - "registration_actions_disabled" => true, - "owner_recovery_audit" => OwnerRecovery.serialize_audit_for_state(result.audit) - } - end - - defp build_owner_form(owner_email) do - to_form(%{"email" => owner_email || "", "password" => "", "password_confirmation" => ""}, as: :owner) - end - - defp build_recovery_form(owner_email, params \\ %{}) do - to_form( - %{ - "email" => params["email"] || owner_email || "", - "password" => "", - "password_confirmation" => "", - "verification_phrase" => params["verification_phrase"] || "", - "verification_ack" => normalize_checkbox(params["verification_ack"]) - }, - as: :owner_recovery - ) - end - - defp normalize_checkbox(true), do: true - defp normalize_checkbox("true"), do: true - defp normalize_checkbox("on"), do: true - defp normalize_checkbox(_value), do: false - - defp bootstrap_intro_copy(:desktop), - do: - "This is a brand-new desktop install. We’ll verify the local runtime, create your first admin account, and then continue into setup." - - defp bootstrap_intro_copy(_deployment_mode), - do: - "This is a brand-new install. We’ll verify the runtime, create the first admin account, and then continue into the rest of setup." - - defp ready_intro_copy(:desktop), - do: - "Sign in to this desktop install. The first-run bootstrap is complete, and public account creation is now disabled." - - defp ready_intro_copy(_deployment_mode), - do: "Sign in to this install. The first-run bootstrap is complete, and public account creation is now disabled." - - defp ready_signed_in_intro_copy(:desktop), - do: - "This desktop install is ready. Continue into dashboard for product work, and use settings when you need auth or Git integration management." - - defp ready_signed_in_intro_copy(_deployment_mode), - do: - "This install is ready. Continue into dashboard for product work, and use settings when you need auth or Git integration management." - - defp prerequisite_banner_message(:timeout), - do: "Some checks timed out. Your system may not be fully ready." - - defp prerequisite_banner_message(_status), - do: "Some system requirements aren't met yet." - - defp prereq_badge_class(:pass), do: "badge-success" - defp prereq_badge_class(:fail), do: "badge-error" - defp prereq_badge_class(:timeout), do: "badge-warning" - defp prereq_badge_class(_status), do: "badge-neutral" - - defp prereq_status_label(:pass), do: "Pass" - defp prereq_status_label(:fail), do: "Fail" - defp prereq_status_label(:timeout), do: "Timeout" - defp prereq_status_label(status), do: status |> to_string() |> String.capitalize() - - defp owner_sign_in_with_token_path(token) do - strategy = Info.strategy!(User, :password) - - strategy_path = - strategy - |> Strategy.routes() - |> Enum.find_value(fn - {path, :sign_in_with_token} -> path - _other -> nil - end) - - path = - Path.join( - "/auth", - String.trim_leading(strategy_path || "/user/password/sign_in_with_token", "/") - ) - - query = URI.encode_query(%{"token" => token}) - "#{path}?#{query}" - end - - defp github_login_enabled? do - if not BootstrapStatus.provider_login_available?() do - false - else - ProviderConfig - |> Ash.Query.filter(provider == ^:github and provider_host == ^"github.com") - |> Ash.read_one(domain: AuthProviders, authorize?: false) - |> case do - {:ok, %ProviderConfig{enabled: true, login_enabled: true}} -> true - _other -> false - end - end - end - - defp github_login_path do - "/auth/providers/github/start?provider_host=github.com" - end + use JidoCodeWeb.ResetLive, title: "Welcome" end diff --git a/lib/jido_code_web/live/project_detail_live.ex b/lib/jido_code_web/live/project_detail_live.ex index 63ee9b35..d2c5b5ec 100644 --- a/lib/jido_code_web/live/project_detail_live.ex +++ b/lib/jido_code_web/live/project_detail_live.ex @@ -1,3961 +1,3 @@ defmodule JidoCodeWeb.ProjectDetailLive do - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.conversation_routes_keep_runtime_and_recovery_liveview_owned - # covers: architecture.frontend_stack.semantic_operator_surfaces_can_use_bounded_hybrid_regions - # covers: architecture.memory_graph_product_adoption.managed_repo_routes_host_memory_and_provenance_inspection - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.source_code_graph_product_adoption.managed_repo_routes_host_semantic_inspection - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.conversation_orchestration.ui_delivery_is_event_driven_and_reconnectable - # covers: architecture.conversation_orchestration.degraded_mode_falls_back_to_persisted_state - # covers: architecture.conversation_orchestration.managed_repo_routes_host_repo_conversations - # covers: architecture.conversation_orchestration.operator_surfaces_show_conversation_work_item_linkage - # covers: architecture.conversation_orchestration.real_llm_turn_execution_replaces_surface_simulation - # covers: architecture.conversation_orchestration.route_level_runtime_readiness_and_continuity_are_operator_readable - # covers: setup.onboarding.post_bootstrap_surfaces_adopt_control_plane_language - use JidoCodeWeb, :live_view - - alias JidoCode.Conversations.PubSub, as: ConversationPubSub - alias JidoCode.Conversations.RuntimeReadiness - alias JidoCode.ManagedRepoRoutes - alias JidoCode.LLMSelection - alias JidoCodeWeb.OperatorShell - alias JidoCode.Workbench.ProjectDetail - alias JidoCode.Workbench.ProjectConversation - alias JidoCode.Workbench.ProjectMemoryInspection - alias JidoCode.Workbench.ProjectSemanticInspection - alias JidoCode.Workbench.ProjectDetailWorkflowKickoff - alias JidoCode.Workbench.ProjectWorkspaceBinding - alias JidoCode.Workbench.ProjectWorkspaceBindingNotice - - @conversation_degraded_mode_message "Live conversation stream unavailable. Showing the latest repository conversation snapshot only." - @detail_subject_order [:readiness, :work, :knowledge] - @detail_sections [:overview, :conversations, :semantic, :memory, :workflows] - @repositories_path "/repos" - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:project_detail, nil) - |> assign(:project_load_error, nil) - |> assign(:semantic_inspection, nil) - |> assign(:semantic_action_feedback, nil) - |> assign(:memory_inspection, nil) - |> assign(:memory_action_feedback, nil) - |> assign(:repo_intake_surface, empty_conversation_surface()) - |> assign(:conversation_surface, empty_conversation_surface()) - |> assign(:conversation_roster, []) - |> assign(:conversation_roster_notice, nil) - |> assign(:conversation_snapshot, nil) - |> assign(:conversation_events, []) - |> assign(:conversation_runtime, empty_conversation_runtime()) - |> assign(:conversation_last_event_sequence, 0) - |> assign(:conversation_input, "") - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign(:conversation_stream_mode, :idle) - |> assign(:conversation_stream_degraded_reason, nil) - |> assign(:conversation_stream_discontinuity_count, 0) - |> assign(:conversation_degraded_mode_message, @conversation_degraded_mode_message) - |> assign(:selected_work_item_id, nil) - |> assign(:workflow_launch_states, %{}) - |> assign(:return_to_path, @repositories_path) - |> assign(:selected_detail_subject, :readiness) - |> assign(:selected_detail_section, :overview) - |> assign( - :workspace_binding_form, - to_form(%{"workspace_environment" => "sprite", "workspace_path" => ""}, as: :workspace_binding) - ) - |> assign(:workspace_binding_form_values, %{"workspace_environment" => "sprite", "workspace_path" => ""}) - |> assign(:workspace_binding_feedback, nil) - |> assign(:workspace_binding_feedback_kind, :info) - |> assign(:supported_workflows, ProjectDetailWorkflowKickoff.supported_workflows())} - end - - @impl true - def handle_params(params, _uri, socket) do - project_id = Map.get(params, "id") - return_to_path = normalize_return_to_path(Map.get(params, "return_to")) - selected_work_item_id = present_optional_string(Map.get(params, "work_item_id")) - {selected_detail_subject, selected_detail_section} = normalize_detail_selection(params) - - socket = - socket - |> maybe_unsubscribe_conversation() - |> case do - socket -> - case ProjectDetail.load(project_id) do - {:ok, project_detail} -> - socket - |> assign(:project_detail, project_detail) - |> assign(:project_load_error, nil) - |> assign(:semantic_inspection, ProjectSemanticInspection.load_repo_detail(project_detail)) - |> assign(:semantic_action_feedback, nil) - |> assign(:memory_inspection, load_memory_inspection(project_detail, return_to_path)) - |> assign(:memory_action_feedback, nil) - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign(:selected_work_item_id, selected_work_item_id) - |> assign_workspace_binding_form(project_detail) - |> assign_project_conversation(project_detail, selected_work_item_id) - - {:error, project_load_error} -> - socket - |> assign(:project_detail, nil) - |> assign(:project_load_error, project_load_error) - |> assign(:semantic_inspection, nil) - |> assign(:semantic_action_feedback, nil) - |> assign(:memory_inspection, nil) - |> assign(:memory_action_feedback, nil) - |> assign( - :workspace_binding_form, - to_form(%{"workspace_environment" => "sprite", "workspace_path" => ""}, as: :workspace_binding) - ) - |> assign(:workspace_binding_form_values, %{"workspace_environment" => "sprite", "workspace_path" => ""}) - |> clear_project_conversation() - end - end - - {:noreply, - socket - |> assign(:workflow_launch_states, %{}) - |> assign(:return_to_path, return_to_path) - |> assign(:selected_detail_subject, selected_detail_subject) - |> assign(:selected_detail_section, selected_detail_section)} - end - - @impl true - def handle_event("kickoff_workflow", %{"workflow_name" => workflow_name}, socket) do - workflow_key = normalize_workflow_name(workflow_name) - - kickoff_result = - ProjectDetailWorkflowKickoff.kickoff( - socket.assigns.project_detail, - workflow_name, - initiating_actor(socket), - return_to: current_project_detail_path(socket.assigns) - ) - - {:noreply, put_workflow_launch_state(socket, workflow_key, kickoff_result)} - end - - @impl true - def handle_event("recover_semantic_graph", _params, socket) do - project_detail = Map.get(socket.assigns, :project_detail) - - case ProjectSemanticInspection.recover(project_detail) do - {:ok, %{inspection: inspection, feedback: feedback}} -> - {:noreply, - socket - |> assign(:semantic_inspection, inspection) - |> assign(:semantic_action_feedback, feedback)} - - {:error, %{inspection: inspection, feedback: feedback}} -> - {:noreply, - socket - |> assign(:semantic_inspection, inspection) - |> assign(:semantic_action_feedback, feedback)} - end - end - - @impl true - def handle_event("recover_memory_graph", _params, socket) do - project_detail = Map.get(socket.assigns, :project_detail) - - case ProjectMemoryInspection.recover(project_detail) do - {:ok, %{inspection: inspection, feedback: feedback}} -> - {:noreply, - socket - |> assign(:memory_inspection, inspection) - |> assign(:memory_action_feedback, feedback)} - - {:error, %{inspection: inspection, feedback: feedback}} -> - {:noreply, - socket - |> assign(:memory_inspection, inspection) - |> assign(:memory_action_feedback, feedback)} - end - end - - @impl true - def handle_event("open_repo_conversation", _params, socket) do - open_result = - ProjectConversation.open_repo_detail( - socket.assigns.project_detail, - actor: initiating_actor(socket) - ) - - case open_result do - {:ok, %{conversation: _conversation, snapshot: _snapshot}} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign(:conversation_input, "") - |> assign_project_conversation( - socket.assigns.project_detail, - nil - )} - - {:error, notice} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, notice) - |> assign(:conversation_action_feedback_kind, :error)} - end - end - - @impl true - def handle_event("open_selected_conversation", _params, socket) do - case socket.assigns.selected_work_item_id do - work_item_id when is_binary(work_item_id) -> - case ProjectConversation.open_work_item_detail( - work_item_id, - actor: initiating_actor(socket) - ) do - {:ok, %{conversation: _conversation, snapshot: _snapshot}} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign(:conversation_input, "") - |> assign_project_conversation( - socket.assigns.project_detail, - work_item_id - )} - - {:error, notice} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, notice) - |> assign(:conversation_action_feedback_kind, :error)} - end - - _other -> - {:noreply, socket} - end - end - - @impl true - def handle_event("change_workspace_binding", %{"workspace_binding" => params}, socket) do - {:noreply, - socket - |> assign_workspace_binding_form(params) - |> assign(:workspace_binding_feedback, nil) - |> assign(:workspace_binding_feedback_kind, :info)} - end - - @impl true - def handle_event("save_workspace_binding", %{"workspace_binding" => params}, socket) do - workspace_binding_attrs = normalize_workspace_binding_form_params(params) - managed_repo_id = socket.assigns.project_detail && socket.assigns.project_detail.managed_repo_id - - case ProjectWorkspaceBinding.update(managed_repo_id, workspace_binding_attrs, initiating_actor(socket)) do - {:ok, %{workspace_settings: workspace_settings}} -> - {:noreply, - socket - |> refresh_project_detail_surface() - |> assign_workspace_binding_form(workspace_binding_form_values(workspace_settings)) - |> assign(:workspace_binding_feedback, %{ - error_type: "managed_repo_workspace_binding_updated", - detail: "Repo-scoped workspace binding saved for #{socket.assigns.project_detail.github_full_name}.", - remediation: - "Conversation, semantic, memory, and workflow readiness on this route now read from the updated repo-scoped binding." - }) - |> assign(:workspace_binding_feedback_kind, :info)} - - {:error, error} -> - {:noreply, - socket - |> assign_workspace_binding_form(params) - |> assign(:workspace_binding_feedback, error) - |> assign(:workspace_binding_feedback_kind, :error)} - end - end - - @impl true - def handle_event("focus_repo_intake_conversation", _params, socket) do - {:noreply, - push_patch( - socket, - to: - project_detail_conversation_path( - socket.assigns.project_detail, - socket.assigns.return_to_path, - nil - ) - )} - end - - @impl true - def handle_event("focus_work_item_conversation", %{"work_item_id" => work_item_id}, socket) do - case present_optional_string(work_item_id) do - nil -> - {:noreply, socket} - - selected_work_item_id -> - {:noreply, - push_patch( - socket, - to: - project_detail_conversation_path( - socket.assigns.project_detail, - socket.assigns.return_to_path, - selected_work_item_id - ) - )} - end - end - - def handle_event("update_conversation_input", %{"input" => value}, socket) do - {:noreply, assign(socket, :conversation_input, value)} - end - - def handle_event("send_conversation", params, socket) do - input = - params - |> Map.get("input", socket.assigns.conversation_input) - |> normalize_optional_string() - |> Kernel.||("") - - conversation_id = conversation_id(socket) - - cond do - input == "" -> - {:noreply, socket} - - not is_binary(conversation_id) -> - {:noreply, - socket - |> assign(:conversation_action_feedback, %{ - error_type: "project_detail_conversation_missing", - detail: "Open a repository conversation before submitting work.", - remediation: "Use the repository conversation action above and then retry the request." - }) - |> assign(:conversation_action_feedback_kind, :error)} - - socket.assigns.conversation_snapshot && - socket.assigns.conversation_snapshot.status == :paused -> - {:noreply, - socket - |> assign(:conversation_action_feedback, %{ - error_type: "project_detail_conversation_paused", - detail: "Resume the repository conversation before submitting new work.", - remediation: "Use the Resume control and then retry the request." - }) - |> assign(:conversation_action_feedback_kind, :error)} - - true -> - case JidoCode.AgentWorkspace.handle_conversation_command( - conversation_id, - conversation_input_command(socket, input, params), - actor: initiating_actor(socket) - ) do - {:ok, snapshot} -> - {:noreply, - socket - |> assign(:conversation_input, "") - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign_conversation_snapshot(snapshot)} - - {:error, reason} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, %{ - error_type: "project_detail_conversation_submit_failed", - detail: "Repository conversation work could not be submitted (#{inspect(reason)}).", - remediation: "Retry the request or reopen the repository conversation if the prior turn cannot continue." - }) - |> assign(:conversation_action_feedback_kind, :error)} - end - end - end - - def handle_event("pause_conversation", _params, socket) do - dispatch_conversation_control(socket, "session.pause", %{ - reason: "Operator paused the repository conversation." - }) - end - - def handle_event("resume_conversation", _params, socket) do - dispatch_conversation_control(socket, "session.resume", %{}) - end - - def handle_event("stop_conversation_turn", _params, socket) do - if is_binary(active_child_work_id(socket.assigns.conversation_snapshot)) do - case JidoCode.AgentWorkspace.handle_conversation_command( - conversation_id(socket), - %{type: "turn.stop", payload: %{reason: "Operator requested a stop."}}, - actor: initiating_actor(socket) - ) do - {:ok, snapshot} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign_conversation_snapshot(snapshot)} - - {:error, reason} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, %{ - error_type: "project_detail_conversation_stop_failed", - detail: "The active repository conversation turn could not be stopped (#{inspect(reason)}).", - remediation: "Retry the stop request or let the active turn settle before continuing." - }) - |> assign(:conversation_action_feedback_kind, :error)} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_info({:conversation_event, event}, socket) do - if conversation_event_applies?(socket, event) do - event_sequence = map_get(event, :sequence, "sequence") - - cond do - not is_integer(event_sequence) -> - {:noreply, socket} - - event_sequence <= socket.assigns.conversation_last_event_sequence -> - {:noreply, socket} - - event_sequence == socket.assigns.conversation_last_event_sequence + 1 -> - {:noreply, sync_conversation_event(socket, event)} - - true -> - {:noreply, recover_project_conversation_gap(socket)} - end - else - {:noreply, socket} - end - end - - def handle_info(_message, socket), do: {:noreply, socket} - - @impl true - def render(assigns) do - ~H""" - -
-

Managed repo detail

-

- Launch builtin workflows from the managed-repository control view with governed run traceability. -

-
- - <.operator_state_notice - :if={@project_load_error} - id="project-detail-load-error" - title="Managed repository detail is unavailable" - state={@project_load_error} - kind={:error} - /> - -
-
-
-

- {@project_detail.github_full_name} -

-

- {@project_detail.name} -

-
- <.link id="project-detail-return-link" class="btn btn-sm btn-outline" navigate={@return_to_path}> - Back to {return_to_label(@return_to_path)} - -
-
- -
- <.subject_tree_shell - id="project-detail-shell" - breadcrumbs={project_detail_breadcrumbs(assigns)} - parent_subjects={project_detail_parent_subjects(assigns)} - child_subjects={detail_section_items(assigns)} - child_nav_id="project-detail-section-nav" - child_nav_label={project_detail_child_nav_label(assigns.selected_detail_subject)} - child_nav_heading={project_detail_child_nav_heading(assigns.selected_detail_subject)} - child_nav_summary={project_detail_child_nav_summary(assigns.selected_detail_subject)} - sidebar_id="project-detail-section-sidebar" - content_id="project-detail-section-content" - > - <.subject_pane pane={project_detail_selected_pane(assigns)}> -
-
-

Repository overview

-

- Start with repository identity, launch posture, and high-level workflow readiness before drilling into work, knowledge, or execution detail. -

-
- - <.vue_surface - id="project-detail-overview-widget" - component="ProjectDetailOverviewWidget" - socket={@socket} - props={project_detail_overview_props(assigns)} - /> - -
-
-
-

Repo-scoped workspace binding

-

- Setup runtime defaults only seed new imports. This repository keeps its own execution binding here. -

-
- - {workspace_binding_badge_label(@project_detail)} - -
- -
-
-

Execution mode

-

- {workspace_binding_environment_label(@project_detail)} -

-
-
-

Repository workspace path

-

- {workspace_binding_path_label(@project_detail)} -

-
-
-

Derived parent directory

-

- {workspace_binding_root_label(@project_detail)} -

-
-
-

Route runtime readiness

-

- {workspace_binding_route_readiness_label(@project_detail)} -

-
-
- - <.operator_state_notice - :if={@workspace_binding_feedback} - id="project-detail-workspace-binding-feedback" - title="Repo workspace binding update" - state={@workspace_binding_feedback} - kind={@workspace_binding_feedback_kind} - compact={true} - /> - - <.form - id="project-detail-workspace-binding-form" - for={@workspace_binding_form} - phx-change="change_workspace_binding" - phx-submit="save_workspace_binding" - class="space-y-3" - > -
- <.input - id="project-detail-workspace-binding-environment-input" - field={@workspace_binding_form[:workspace_environment]} - type="select" - label="Workspace binding mode" - options={[{"Local", "local"}, {"Cloud default only", "sprite"}]} - /> - - <.input - :if={workspace_binding_form_local?(@workspace_binding_form_values)} - id="project-detail-workspace-binding-path-input" - field={@workspace_binding_form[:workspace_path]} - type="text" - label="Repository workspace path" - placeholder="/absolute/path/to/this/repository" - autocomplete="off" - /> -
- -

- {workspace_binding_derived_root_note(@workspace_binding_form_values)} -

- -

- Cloud-default mode leaves this repository unbound until you later save a local workspace path here. -

- -

- This updates only {@project_detail.github_full_name}. It does not rewrite setup defaults or sibling repositories. -

- -
- -
-
-
-
-

{section.label}

-

- {section.summary} -

-
- - {section.badge.label} - -
- - <.link - id={"project-detail-overview-open-#{section.section}"} - class="btn btn-xs btn-outline" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: section.section, - work_item_id: @selected_work_item_id - ) - } - > - Open {section.label} - -
-
-
- -
-
-

Repository conversation

-

- Repository conversations stay product-owned on this managed-repository route: repo intake stays bounded here, governed work-item conversations stay resumable here, and live delivery recovers from the latest durable snapshot when the stream degrades. -

-
- -
-
-

- Current focus -

-

- {conversation_workspace_focus_label( - @selected_work_item_id, - @conversation_surface, - @repo_intake_surface - )} -

-

- {conversation_workspace_focus_summary( - @selected_work_item_id, - @conversation_surface, - @repo_intake_surface - )} -

-
- -
-

- Governed work -

-

- {length(@conversation_roster)} active thread{if length(@conversation_roster) == 1, do: "", else: "s"} -

-

- {conversation_governed_summary(@conversation_roster)} -

-
- -
-

- Runtime posture -

-

- {conversation_workspace_runtime_label( - @conversation_runtime, - @conversation_stream_mode - )} -

-

- {conversation_workspace_runtime_summary( - @conversation_runtime, - @conversation_stream_mode - )} -

-
-
- - <.operator_state_notice - :if={@conversation_action_feedback} - id="project-detail-conversation-feedback" - title="Repository conversation update" - state={@conversation_action_feedback} - kind={@conversation_action_feedback_kind} - /> - -
- - -
-
-
-
-

- {selected_conversation_title(@selected_work_item_id, @conversation_surface)} -

-

- {selected_conversation_summary(@selected_work_item_id, @conversation_surface)} -

-

- Following governed conversation for WorkItem {@selected_work_item_id}. -

-
-
- <.conversation_role_badge - :if={@conversation_surface.conversation} - id="project-detail-conversation-role" - scope={Map.get(@conversation_surface.conversation, :scope)} - attachment_mode={Map.get(@conversation_surface.conversation, :attachment_mode)} - work_item_id={Map.get(@conversation_surface.conversation, :work_item_id)} - historical={!is_nil(@conversation_surface.historical_conversation)} - /> - <.conversation_status_badge - :if={@conversation_surface.conversation} - id="project-detail-conversation-status" - status={Map.get(@conversation_surface.conversation, :status)} - /> -

- {Map.get(@conversation_surface.conversation, :id)} -

-
-
- - <.operator_state_notice - :if={conversation_notice_visible?(@conversation_surface)} - id="project-detail-conversation-notice" - title="Selected conversation state" - state={@conversation_surface.notice} - kind={:warning} - compact={true} - /> - -
-
-
-

Conversation runtime readiness

-

- Selected LLM, repo-scoped workspace binding, and readiness stay visible on the route before runtime metadata. -

-
- - {conversation_runtime_status_label(@conversation_runtime)} - -
- -
-
-

- Selected LLM -

-

- {conversation_runtime_llm_label(@conversation_runtime)} -

-
-
-

- Selection source -

-

- {conversation_runtime_source_label(@conversation_runtime)} -

-
-
-

- Repo workspace path -

-

- {conversation_runtime_workspace_label(@conversation_runtime)} -

-
-
- - <.operator_state_notice - :if={conversation_runtime_notice_visible?(@conversation_runtime)} - id="project-detail-conversation-runtime-notice" - title="Conversation runtime readiness" - state={@conversation_runtime.notice} - kind={:error} - compact={true} - > - <:actions> - <.link - :if={workspace_binding_repair_visible?(@conversation_runtime.notice)} - id="project-detail-conversation-runtime-repair" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :overview, - work_item_id: @selected_work_item_id, - anchor: "project-detail-workspace-binding-panel" - ) - } - class="btn btn-sm btn-outline" - > - Repair workspace binding - - - -

- Latest transcript and governed linkage remain visible below while this repository's workspace binding recovers. -

- -
- -
-

Conversation stream degraded

-

{@conversation_degraded_mode_message}

-
-
- - <%= cond do %> - <% @conversation_surface.snapshot -> %> -
-
-
-
-
-

Conversation transcript

-

- Recent event-driven transcript and operator controls for the selected conversation. -

-
-
- -
-
-

- Stream continuity -

-
- <.conversation_stream_badge - id="project-detail-conversation-stream-mode" - stream_mode={@conversation_stream_mode} - discontinuity_count={@conversation_stream_discontinuity_count} - /> -
-

- {conversation_continuity_detail( - @conversation_stream_mode, - @conversation_stream_discontinuity_count - )} -

-
- -
-

- Latest activity -

-

- {conversation_latest_activity_label(@conversation_surface) || - "No recent conversation activity"} -

-

- Route continuity stays anchored to the latest durable snapshot. -

-
- -
-

- Current turn -

-

- {conversation_turn_summary(@conversation_snapshot)} -

-

- {conversation_sequence_summary( - @conversation_last_event_sequence, - @conversation_stream_discontinuity_count - )} -

-
-
-
- -
- <%= for event <- @conversation_events do %> - <.conversation_event_row - id={"project-detail-conversation-event-#{map_get(event, :id, "id")}"} - sequence={map_get(event, :sequence, "sequence")} - label={conversation_event_label(event)} - event_name={map_get(event, :name, "name")} - occurred_at={format_time(map_get(event, :occurred_at, "occurred_at"))} - title={conversation_event_title(event)} - excerpt={conversation_event_excerpt(event)} - tone={conversation_event_tone(event)} - /> - <% end %> -
- -
-
-

Input required

-

- {conversation_clarification_prompt(@conversation_snapshot) || - "The active turn is waiting on clarification."} -

-
- -
- - -
-
-
- - -
- <% @conversation_surface.conversation || @conversation_surface.work_item -> %> -
-
-

- {conversation_surface_summary(@conversation_surface)} -

-

- Governed work item: {Map.get(@conversation_surface.work_item, :summary)} -

-

- Historical lineage preserved from {@conversation_surface.historical_conversation.id}. -

-
- - -
-
-
- <% true -> %> -
-
-

- Open a repository conversation to coordinate repo-scoped work without leaving the managed-repository detail route. -

- -
-
- <% end %> -
-
-
- -
-
-
-

Semantic repository inspection

-

- Semantic source-code graph insights stay repo-scoped, bounded, product-owned, and tied to this repository's own workspace binding. -

-
-
- - <.operator_state_notice - :if={@semantic_action_feedback} - id="project-detail-semantic-feedback" - title="Semantic graph recovery update" - state={@semantic_action_feedback} - kind={:info} - /> - - <.operator_state_notice - :if={semantic_notice_visible?(@semantic_inspection)} - id="project-detail-semantic-notice" - title="Semantic graph status" - state={@semantic_inspection.notice} - kind={semantic_notice_kind(@semantic_inspection)} - /> - - <.vue_surface - id="project-detail-semantic-explorer-widget" - component="ProjectDetailSemanticExplorerWidget" - socket={@socket} - props={project_detail_semantic_explorer_props(assigns)} - events={%{"requestRecovery" => "recover_semantic_graph"}} - fallback_title="Interactive semantic explorer temporarily unavailable" - fallback_detail="This repository is using the server-rendered semantic summary while the richer explorer is unavailable." - > -
-
-
-

Modules

-

- {semantic_group_count(@semantic_inspection.summary, :modules)} -

-
-
-

Functions

-

{semantic_result_count(@semantic_inspection.functions)}

-
-
-

Runtime patterns

-

- {semantic_result_count(@semantic_inspection.runtime_patterns)} -

-
-
-

Impact relationships

-

{semantic_result_count(@semantic_inspection.impact)}

-
-
- -
-
-

Modules

-

- No module summaries are currently available. -

-
    -
  • - {Map.get(item, :module_name) || "Unnamed module"} -
  • -
-
- -
-

Impact

-

- No bounded impact relationships are currently available. -

-
    -
  • - {Map.get(item, :predicate_name) || "relationship"} -
  • -
-
-
-
- -
- -
-
-
-

Repository memory and provenance

-

- Durable coding memory and workflow provenance stay repository-scoped, freshness-aware, product-owned, and tied to this repository's own workspace binding. -

-
-
- - <.operator_state_notice - :if={@memory_action_feedback} - id="project-detail-memory-feedback" - title="Memory graph recovery update" - state={@memory_action_feedback} - kind={memory_feedback_kind(@memory_action_feedback)} - /> - - <.memory_status_notice - :if={is_map(@memory_inspection.notice)} - id="project-detail-memory-notice" - title="Repository memory status" - state={@memory_inspection.notice} - kind={Map.get(@memory_inspection, :notice_kind, :warning)} - recovery={Map.get(@memory_inspection, :recovery)} - recover_event={nil} - recover_id="project-detail-memory-recover" - /> - -
-
-

Durable memories

-

- {memory_group_count(@memory_inspection.summary, :memories)} -

-
-
-

Conversation recall

-

- {memory_projection_count(@memory_inspection.conversation_recall)} -

-
-
-

Workflow provenance

-

- {memory_group_count(@memory_inspection.summary, :provenance)} -

-
-
-

Memory state

-

- {Map.get(@memory_inspection.graph, :state, :unavailable)} -

-
-
-

Validated revision

-

- {Map.get(@memory_inspection.graph, :validated_revision) || "Not validated"} -

-
-
- -
-
-

Durable memory

-

- No durable memory is currently available for this repository. -

-
    -
  • -

    - {Map.get(item, :memory_kind) || "Memory"} -

    -

    {Map.get(item, :content)}

    -

    - Code anchor: {Map.get(item, :module_name)} -

    - <.memory_link_groups - dom_prefix={"project-detail-memory-item-#{memory_item_dom_id(item)}"} - item={item} - /> -
  • -
-
- -
-

Conversation-origin recall

-

- Bounded origin summaries stay explorable here without turning repository memory into a transcript browser. -

-

- No bounded conversation-origin recall is currently available for this repository. -

-
    -
  • - <.conversation_origin_card - dom_prefix={"project-detail-conversation-recall-item-#{index}"} - item={item} - /> -
  • -
-
- -
-

Workflow provenance

-

- No workflow provenance is currently available for this repository. -

-
    -
  • -

    - {Map.get(item, :label) || Map.get(item, :provenance_kind) || "Provenance"} -

    -

    {Map.get(item, :content)}

    -

    - Code anchor: {Map.get(item, :module_name)} -

    - <.memory_link_groups - dom_prefix={"project-detail-provenance-item-#{memory_item_dom_id(item)}"} - item={item} - /> -
  • -
-
-
-
- -
-
-
-

Workflow launch and defaults

-

- Keep repo-scoped workspace binding, readiness remediation, and governed workflow kickoff together so this route stays an action surface instead of generic repository tooling. -

-
- -
-
-
-

Governed launch posture

-

- {workflow_launch_posture_summary(@project_detail)} -

-
- - {workflow_launch_posture_badge_label(@project_detail)} - -
-
-
- -
-

Managed repository launch defaults

-

- Default branch: {@project_detail.default_branch} -

-

- Repository: {@project_detail.github_full_name} -

-

- Managed repo: {@project_detail.managed_repo_id} -

-
- -
-

- Workflow launch controls are disabled -

-

- Typed readiness state: {project_readiness(@project_detail).error_type} -

-

- {project_readiness(@project_detail).detail} -

-

- {project_readiness(@project_detail).remediation} -

-
- -
-
-
-

- {workflow.label} -

-

- {workflow.name} -

-
- - <%= if project_ready_for_launch?(@project_detail) do %> - - <% else %> - - Launch workflow - - <% end %> - - <.workflow_launch_feedback - feedback={workflow_launch_feedback(@workflow_launch_states, workflow.name)} - dom_prefix={"project-detail-launch-#{workflow_dom_id(workflow.name)}"} - /> -
-
-
- <:footer_actions> - - - <.link - :if={@selected_detail_section == :overview} - id="project-detail-overview-open-conversations-footer" - class="btn btn-sm btn-outline" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :conversations, - work_item_id: @selected_work_item_id - ) - } - > - Open conversations - - - <.link - :if={@selected_detail_section == :overview} - id="project-detail-overview-open-workflows-footer" - class="btn btn-sm btn-outline" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :workflows, - work_item_id: @selected_work_item_id - ) - } - > - Open workflows - - - <.link - :if={@selected_detail_section == :semantic} - id="project-detail-semantic-open-memory" - class="btn btn-sm btn-outline" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :memory, - work_item_id: @selected_work_item_id - ) - } - > - Open memory and provenance - - - <.link - :if={ - @selected_detail_section == :semantic and workspace_binding_repair_visible?(@semantic_inspection.notice) - } - id="project-detail-semantic-repair-workspace" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :overview, - work_item_id: @selected_work_item_id, - anchor: "project-detail-workspace-binding-panel" - ) - } - class="btn btn-sm btn-outline" - > - Repair workspace binding - - - - - <.link - :if={@selected_detail_section == :memory} - id="project-detail-memory-open-semantic" - class="btn btn-sm btn-outline" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :semantic, - work_item_id: @selected_work_item_id - ) - } - > - Open semantic inspection - - - <.link - :if={@selected_detail_section == :memory and workspace_binding_repair_visible?(@memory_inspection.notice)} - id="project-detail-memory-repair-workspace" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :overview, - work_item_id: @selected_work_item_id, - anchor: "project-detail-workspace-binding-panel" - ) - } - class="btn btn-sm btn-outline" - > - Repair workspace binding - - - - - <.link - :if={ - @selected_detail_section == :workflows and - workspace_binding_repair_visible?(project_readiness(@project_detail)) - } - id="project-detail-launch-disabled-repair" - patch={ - project_detail_section_path( - @project_detail, - @return_to_path, - section: :overview, - work_item_id: @selected_work_item_id, - anchor: "project-detail-workspace-binding-panel" - ) - } - class="btn btn-sm btn-outline" - > - Repair workspace binding - - - - -
-
- """ - end - - defp detail_section_items(assigns) do - detail_subject_sections() - |> Map.fetch!(assigns.selected_detail_subject) - |> Enum.map(fn section -> - OperatorShell.child_subject(%{ - id: section, - label: detail_section_label(section), - selected?: assigns.selected_detail_section == section, - summary: detail_section_summary(section, assigns), - badge: detail_section_badge(section, assigns), - pane_id: "project-detail-pane-#{section}", - patch: - project_detail_section_path( - assigns.project_detail, - assigns.return_to_path, - section: section, - work_item_id: assigns.selected_work_item_id - ) - }) - |> Map.put(:section, section) - end) - end - - defp project_detail_breadcrumbs(assigns) do - [ - OperatorShell.breadcrumb(%{ - id: "project-detail-breadcrumb-return", - label: return_to_label(assigns.return_to_path), - navigate: assigns.return_to_path - }), - OperatorShell.breadcrumb(%{ - id: "project-detail-breadcrumb-repo", - label: assigns.project_detail.github_full_name, - patch: - project_detail_subject_path( - assigns.project_detail, - assigns.return_to_path, - :readiness, - assigns.selected_work_item_id - ) - }), - OperatorShell.breadcrumb(%{ - id: "project-detail-breadcrumb-subject", - label: project_detail_subject_label(assigns.selected_detail_subject), - patch: - project_detail_subject_path( - assigns.project_detail, - assigns.return_to_path, - assigns.selected_detail_subject, - assigns.selected_work_item_id - ) - }), - OperatorShell.breadcrumb(%{ - id: "project-detail-breadcrumb-current", - label: detail_section_label(assigns.selected_detail_section), - current?: true - }) - ] - end - - defp project_detail_parent_subjects(assigns) do - Enum.map(@detail_subject_order, fn subject -> - OperatorShell.parent_subject(%{ - id: subject, - label: project_detail_subject_label(subject), - description: project_detail_subject_description(subject), - selected?: assigns.selected_detail_subject == subject, - patch: - project_detail_subject_path( - assigns.project_detail, - assigns.return_to_path, - subject, - assigns.selected_work_item_id - ) - }) - end) - end - - defp project_detail_selected_pane(assigns) do - section = assigns.selected_detail_section - - OperatorShell.pane(%{ - id: "project-detail-pane-#{section}", - title: project_detail_pane_title(section), - summary: project_detail_pane_summary(section) - }) - end - - defp overview_subject_guides(assigns) do - detail_subject_sections() - |> Map.values() - |> List.flatten() - |> Enum.reject(&(&1 == :overview)) - |> Enum.map(fn section -> - %{ - section: section, - label: detail_section_label(section), - summary: detail_section_summary(section, assigns), - badge: detail_section_badge(section, assigns) - } - end) - end - - defp detail_subject_sections, - do: %{readiness: [:overview], work: [:conversations, :workflows], knowledge: [:semantic, :memory]} - - defp project_detail_child_nav_label(:readiness), do: "Repository readiness subjects" - defp project_detail_child_nav_label(:work), do: "Repository work subjects" - defp project_detail_child_nav_label(:knowledge), do: "Repository knowledge subjects" - - defp project_detail_child_nav_heading(:readiness), do: "Readiness" - defp project_detail_child_nav_heading(:work), do: "Work" - defp project_detail_child_nav_heading(:knowledge), do: "Knowledge" - - defp project_detail_child_nav_summary(:readiness) do - "Repository identity, repo-scoped workspace binding, and launch posture stay grouped here before deeper work begins." - end - - defp project_detail_child_nav_summary(:work) do - "Repo intake, governed conversations, and workflow launch stay grouped on this canonical managed-repository route." - end - - defp project_detail_child_nav_summary(:knowledge) do - "Semantic inspection and durable memory stay grouped here for repository-scoped review and recovery." - end - - defp project_detail_subject_path(project_detail, return_to_path, subject, selected_work_item_id) do - project_detail_section_path( - project_detail, - return_to_path, - section: default_detail_section_for_subject(subject), - work_item_id: selected_work_item_id - ) - end - - defp project_detail_subject_label(:readiness), do: "Readiness" - defp project_detail_subject_label(:work), do: "Work" - defp project_detail_subject_label(:knowledge), do: "Knowledge" - - defp project_detail_subject_description(:readiness) do - "Repository identity, workspace binding, and launch posture stay grouped here." - end - - defp project_detail_subject_description(:work) do - "Repo intake, governed conversations, and workflow launch stay grouped here." - end - - defp project_detail_subject_description(:knowledge) do - "Semantic inspection and durable memory stay grouped here." - end - - defp detail_section_label(:overview), do: "Overview" - defp detail_section_label(:conversations), do: "Conversations" - defp detail_section_label(:semantic), do: "Semantic" - defp detail_section_label(:memory), do: "Memory" - defp detail_section_label(:workflows), do: "Workflows" - - defp detail_section_summary(:overview, _assigns), - do: "Repository identity, launch posture, and the next place to drill in." - - defp detail_section_summary(:conversations, assigns) do - governed_count = length(Map.get(assigns, :conversation_roster, [])) - repo_intake_open? = is_map(Map.get(Map.get(assigns, :repo_intake_surface, %{}), :conversation)) - - cond do - repo_intake_open? and governed_count > 0 -> - "Repo intake plus #{governed_count} governed conversation threads." - - repo_intake_open? -> - "Repo intake is open for bounded clarification before work settles." - - governed_count > 0 -> - "#{governed_count} governed work-item conversations remain active here." - - true -> - "Repo intake and governed work stay hosted on this route." - end - end - - defp detail_section_summary(:semantic, _assigns), - do: "Source-code graph freshness, recovery, and bounded structural inspection." - - defp detail_section_summary(:memory, _assigns), - do: "Durable coding memory, bounded conversation-origin recall, workflow provenance, and freshness validation." - - defp detail_section_summary(:workflows, assigns) do - if project_ready_for_launch?(Map.get(assigns, :project_detail)) do - "Launch defaults and governed workflow kickoff are ready." - else - "Workflow launch posture is blocked and needs remediation." - end - end - - defp project_detail_pane_title(:overview), do: "Repository overview" - defp project_detail_pane_title(:conversations), do: "Repository conversation" - defp project_detail_pane_title(:semantic), do: "Semantic inspection" - defp project_detail_pane_title(:memory), do: "Repository memory" - defp project_detail_pane_title(:workflows), do: "Workflow launch" - - defp project_detail_pane_summary(:overview), - do: - "Repository identity, launch posture, and workflow readiness stay together here before you drill into work, knowledge, or execution detail." - - defp project_detail_pane_summary(:conversations), - do: - "Repo intake, governed work-item conversations, and degraded continuity stay product-owned on this managed-repository route." - - defp project_detail_pane_summary(:semantic), - do: - "Freshness, recovery, and bounded graph exploration stay explicit here before semantic context informs operator follow-up." - - defp project_detail_pane_summary(:memory), - do: - "Durable coding memory, bounded conversation-origin recall, workflow provenance, and validation remain product-owned here before memory influences operator decisions." - - defp project_detail_pane_summary(:workflows), - do: - "Builtin workflow launch stays governed and repository-scoped here so blocked remediation and run traceability stay together." - - defp return_to_label(path), do: ManagedRepoRoutes.return_to_label(path) - - defp detail_section_badge(:overview, _assigns), do: nil - - defp detail_section_badge(:conversations, assigns) do - governed_count = length(Map.get(assigns, :conversation_roster, [])) - repo_intake_open? = is_map(Map.get(Map.get(assigns, :repo_intake_surface, %{}), :conversation)) - runtime_status = Map.get(Map.get(assigns, :conversation_runtime, %{}), :status) - snapshot = Map.get(assigns, :conversation_snapshot) - stream_mode = Map.get(assigns, :conversation_stream_mode) - - cond do - runtime_status == :blocked -> %{label: "Blocked", tone: :error} - conversation_pending_clarification(snapshot) -> %{label: "Input", tone: :warning} - stream_mode == :degraded -> %{label: "Snapshot", tone: :warning} - governed_count > 0 -> %{label: Integer.to_string(governed_count), tone: :info} - repo_intake_open? -> %{label: "Intake", tone: :info} - true -> %{label: "Idle", tone: :neutral} - end - end - - defp detail_section_badge(:semantic, assigns) do - detail_section_graph_badge( - Map.get(assigns, :semantic_inspection), - &semantic_notice_visible?/1 - ) - end - - defp detail_section_badge(:memory, assigns) do - detail_section_graph_badge( - Map.get(assigns, :memory_inspection), - &memory_notice_visible?/1 - ) - end - - defp detail_section_badge(:workflows, assigns) do - if project_ready_for_launch?(Map.get(assigns, :project_detail)) do - %{label: "Ready", tone: :success} - else - %{label: "Blocked", tone: :warning} - end - end - - defp detail_section_graph_badge(%{} = inspection, notice_visible?) when is_function(notice_visible?, 1) do - state = - inspection - |> Map.get(:graph, %{}) - |> Map.get(:state, :unavailable) - - tone = - cond do - notice_visible?.(inspection) -> :warning - state in [:ready, "ready"] -> :success - state in [:stale, "stale"] -> :warning - true -> :neutral - end - - %{label: detail_section_state_label(state), tone: tone} - end - - defp detail_section_graph_badge(_inspection, _notice_visible?), - do: %{label: "Unavailable", tone: :neutral} - - defp detail_section_state_label(state) when is_atom(state) do - state - |> Atom.to_string() - |> detail_section_state_label() - end - - defp detail_section_state_label(state) when is_binary(state) do - case String.trim(state) do - "" -> "Unavailable" - value -> value |> String.replace("_", " ") |> String.capitalize() - end - end - - defp detail_section_state_label(_state), do: "Unavailable" - - defp detail_section_badge_class(:success), do: "border-success/50 bg-success/10 text-success" - defp detail_section_badge_class(:warning), do: "border-warning/50 bg-warning/10 text-warning-content" - defp detail_section_badge_class(:error), do: "border-error/50 bg-error/10 text-error" - defp detail_section_badge_class(:info), do: "border-info/50 bg-info/10 text-info" - defp detail_section_badge_class(:neutral), do: "border-base-300 bg-base-200/70 text-base-content/75" - - defp workflow_launch_posture_badge_label(project_detail) do - if project_ready_for_launch?(project_detail), do: "Ready", else: "Blocked" - end - - defp workflow_launch_posture_badge_class(project_detail) do - if project_ready_for_launch?(project_detail) do - detail_section_badge_class(:success) - else - detail_section_badge_class(:warning) - end - end - - defp workflow_launch_posture_summary(project_detail) do - if project_ready_for_launch?(project_detail) do - "Launch defaults are ready and workflow kickoff from this route preserves governed run traceability." - else - readiness = project_readiness(project_detail) - - [Map.get(readiness, :detail), Map.get(readiness, :remediation)] - |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) - |> Enum.join(" ") - |> case do - "" -> - "Workflow launch is currently blocked for this managed repository." - - summary -> - summary - end - end - end - - defp conversation_workspace_focus_label(selected_work_item_id, conversation_surface, repo_intake_surface) do - cond do - is_binary(selected_work_item_id) and is_map(Map.get(conversation_surface, :work_item)) -> - Map.get(Map.get(conversation_surface, :work_item), :summary) || selected_work_item_id - - is_map(Map.get(repo_intake_surface, :conversation)) -> - "Repo intake" - - true -> - "No active selection" - end - end - - defp conversation_workspace_focus_summary(selected_work_item_id, _conversation_surface, repo_intake_surface) do - cond do - is_binary(selected_work_item_id) -> - "Following the productive conversation, transcript, and runtime posture for the selected governed work item." - - is_map(Map.get(repo_intake_surface, :conversation)) -> - "Repo-scoped intake remains available for pre-work clarification and demand shaping." - - true -> - "Open or resume a repository conversation here to coordinate work without leaving the managed-repository route." - end - end - - defp conversation_governed_summary(conversation_roster) when is_list(conversation_roster) do - case length(conversation_roster) do - 0 -> "No governed work-item conversations are active on this repository yet." - 1 -> "One governed work-item thread is currently active and resumable from this route." - count -> "#{count} governed work-item threads are currently active and resumable from this route." - end - end - - defp conversation_workspace_runtime_label(conversation_runtime, :degraded), - do: "#{conversation_runtime_status_label(conversation_runtime)} / snapshot only" - - defp conversation_workspace_runtime_label(conversation_runtime, _stream_mode), - do: conversation_runtime_status_label(conversation_runtime) - - defp conversation_workspace_runtime_summary(conversation_runtime, stream_mode) do - cond do - Map.get(conversation_runtime, :status) == :blocked -> - "Runtime prerequisites are blocked, but the latest durable conversation state remains visible on this route." - - stream_mode == :degraded -> - "Live delivery is degraded, so the conversation subject is showing the latest durable snapshot." - - true -> - "Runtime posture, selected model, and continuity remain legible while work continues." - end - end - - defp empty_conversation_surface do - %{ - available?: false, - managed_repo_id: nil, - conversation: nil, - historical_conversation: nil, - work_item: nil, - snapshot: nil, - recent_events: [], - notice: nil, - action_label: "Open repo conversation" - } - end - - defp empty_conversation_runtime do - %{ - status: :idle, - notice: nil, - llm_selection: nil, - workspace_path: nil - } - end - - defp assign_conversation_runtime(socket, project_detail) do - assign(socket, :conversation_runtime, load_conversation_runtime(project_detail)) - end - - defp load_conversation_runtime(%{} = project_detail) do - managed_repo_id = - project_detail - |> Map.get(:managed_repo_id) - |> present_optional_string() - - llm_selection = resolve_runtime_llm_selection(project_detail) - workspace_path = project_detail |> runtime_workspace_path() |> present_optional_string() - - case managed_repo_id do - nil -> - %{ - status: :blocked, - notice: %{ - error_type: "conversation_runtime_repo_scope_invalid", - detail: "Managed repository scope is missing for real conversation runtime.", - remediation: "Open the repository from a managed-repository route and retry the conversation." - }, - llm_selection: llm_selection, - workspace_path: workspace_path - } - - _managed_repo_id -> - case RuntimeReadiness.resolve(managed_repo_id) do - {:ok, readiness} -> - %{ - status: :ready, - notice: nil, - llm_selection: LLMSelection.summary(readiness.llm_selection), - workspace_path: readiness.workspace_path - } - - {:error, %{} = notice} -> - %{ - status: :blocked, - notice: notice, - llm_selection: llm_selection, - workspace_path: workspace_path - } - end - end - end - - defp load_conversation_runtime(_project_detail), do: empty_conversation_runtime() - - defp resolve_runtime_llm_selection(project_detail) do - case LLMSelection.resolve_from_project_detail(project_detail) do - {:ok, selection} -> LLMSelection.summary(selection) - _other -> nil - end - end - - defp runtime_workspace_path(%{} = project_detail) do - ProjectDetail.workspace_path(project_detail) - end - - defp runtime_workspace_path(_project_detail), do: nil - - defp refresh_project_detail_surface(socket) do - case socket.assigns.project_detail do - %{id: project_id} when is_binary(project_id) -> - case ProjectDetail.load(project_id) do - {:ok, project_detail} -> - socket - |> assign(:project_detail, project_detail) - |> assign(:project_load_error, nil) - |> assign(:semantic_inspection, ProjectSemanticInspection.load_repo_detail(project_detail)) - |> assign(:memory_inspection, load_memory_inspection(project_detail, socket.assigns.return_to_path)) - |> assign_workspace_binding_form(project_detail) - |> assign_project_conversation(project_detail, socket.assigns.selected_work_item_id) - - {:error, project_load_error} -> - socket - |> assign(:project_load_error, project_load_error) - |> assign(:semantic_inspection, nil) - |> assign(:memory_inspection, nil) - |> clear_project_conversation() - end - - _other -> - socket - end - end - - defp assign_workspace_binding_form(socket, %{} = source) do - form_values = workspace_binding_form_values(source) - - socket - |> assign(:workspace_binding_form_values, form_values) - |> assign(:workspace_binding_form, to_form(form_values, as: :workspace_binding)) - end - - defp assign_workspace_binding_form(socket, source) do - assign_workspace_binding_form(socket, workspace_binding_form_values(source)) - end - - defp workspace_binding_form_values(%{} = source) do - binding = workspace_binding_form_source(source) - - %{ - "workspace_environment" => - binding - |> Map.get("workspace_environment", Map.get(binding, :workspace_environment)) - |> workspace_binding_form_environment(), - "workspace_path" => - binding - |> Map.get("workspace_path", Map.get(binding, :workspace_path)) - |> normalize_optional_string() || "" - } - end - - defp workspace_binding_form_values(_source), - do: %{"workspace_environment" => "sprite", "workspace_path" => ""} - - defp workspace_binding_form_source(%{workspace_environment: _workspace_environment} = binding), - do: normalize_workspace_binding_form_params(binding) - - defp workspace_binding_form_source(%{"workspace_environment" => _workspace_environment} = binding), - do: normalize_workspace_binding_form_params(binding) - - defp workspace_binding_form_source(%{workspace_path: _workspace_path} = binding), - do: normalize_workspace_binding_form_params(binding) - - defp workspace_binding_form_source(%{"workspace_path" => _workspace_path} = binding), - do: normalize_workspace_binding_form_params(binding) - - defp workspace_binding_form_source(source), do: ProjectDetail.workspace_binding(source) - - defp normalize_workspace_binding_form_params(params) when is_map(params) do - %{ - "workspace_environment" => - params - |> map_get(:workspace_environment, "workspace_environment") - |> workspace_binding_form_environment(), - "workspace_path" => - params - |> map_get(:workspace_path, "workspace_path") - |> normalize_optional_string() - } - end - - defp normalize_workspace_binding_form_params(_params), - do: %{"workspace_environment" => "sprite", "workspace_path" => nil} - - defp workspace_binding_form_environment(:local), do: "local" - defp workspace_binding_form_environment("local"), do: "local" - defp workspace_binding_form_environment(_workspace_environment), do: "sprite" - - defp workspace_binding_form_local?(%{} = form_values) do - form_values - |> Map.get("workspace_environment") - |> workspace_binding_form_environment() == "local" - end - - defp workspace_binding_form_local?(_form_values), do: false - - defp workspace_binding_derived_root_note(%{} = form_values) do - case form_values |> Map.get("workspace_path") |> normalize_optional_string() do - nil -> - "Choose the absolute path for this repository. The parent directory shown below is derived from that saved path." - - workspace_path -> - if Path.type(workspace_path) == :absolute do - "Derived parent directory: #{Path.dirname(Path.expand(workspace_path))}" - else - "Choose an absolute path for this repository. The parent directory shown below is derived from that saved path." - end - end - end - - defp workspace_binding_derived_root_note(_form_values) do - "Choose the absolute path for this repository. The parent directory shown below is derived from that saved path." - end - - defp workspace_binding_save_button_label(%{} = form_values) do - if workspace_binding_form_local?(form_values) do - "Save repo workspace path" - else - "Save cloud-default binding" - end - end - - defp workspace_binding_save_button_label(_form_values), do: "Save repo workspace binding" - - defp workspace_binding_badge_label(project_detail) do - binding = ProjectDetail.workspace_binding(project_detail) - - cond do - binding.bound? -> "Bound" - binding.local? -> "Needs path" - true -> "Cloud default only" - end - end - - defp workspace_binding_badge_tone(project_detail) do - binding = ProjectDetail.workspace_binding(project_detail) - - cond do - binding.bound? -> :success - binding.local? -> :warning - true -> :neutral - end - end - - defp workspace_binding_environment_label(project_detail) do - case ProjectDetail.workspace_binding(project_detail).workspace_environment do - :local -> "Local" - _other -> "Cloud default only" - end - end - - defp workspace_binding_path_label(project_detail) do - case ProjectDetail.workspace_binding(project_detail).workspace_path do - workspace_path when is_binary(workspace_path) -> workspace_path - _other -> "No repo-scoped local workspace path saved" - end - end - - defp workspace_binding_root_label(project_detail) do - case ProjectDetail.workspace_binding(project_detail).workspace_root do - workspace_root when is_binary(workspace_root) -> workspace_root - _other -> "Derived after a local path is saved" - end - end - - defp workspace_binding_route_readiness_label(project_detail) do - case project_readiness(project_detail) do - %{status: :ready} -> "Ready" - _other -> "Blocked" - end - end - - defp workspace_binding_repair_visible?(%{} = state) do - state - |> Map.get(:error_type, Map.get(state, "error_type")) - |> normalize_optional_string() - |> case do - "conversation_runtime_workspace_binding_missing" -> true - "conversation_runtime_workspace_binding_unavailable" -> true - "managed_repo_workspace_binding_missing" -> true - "managed_repo_workspace_binding_unavailable" -> true - "semantic_workspace_binding_unavailable" -> true - "memory_workspace_binding_unavailable" -> true - _other -> false - end - end - - defp workspace_binding_repair_visible?(_state), do: false - - defp assign_project_conversation(socket, project_detail, selected_work_item_id) do - previous_conversation_id = conversation_id(socket) - actor = initiating_actor(socket) - - repo_intake_surface = - ProjectConversation.load_repo_detail(project_detail, actor: actor) - - roster_result = ProjectConversation.load_active_work_item_roster(project_detail, actor: actor) - - effective_selected_work_item_id = - resolve_selected_work_item_id( - selected_work_item_id, - repo_intake_surface, - roster_result.entries - ) - - selected_surface = - case effective_selected_work_item_id do - work_item_id when is_binary(work_item_id) -> - ProjectConversation.load_work_item_linkage(work_item_id, actor: actor) - - _other -> - repo_intake_surface - end - - socket - |> assign(:repo_intake_surface, repo_intake_surface) - |> assign(:conversation_roster, roster_result.entries) - |> assign(:conversation_roster_notice, roster_result.notice) - |> assign(:selected_work_item_id, effective_selected_work_item_id) - |> assign_conversation_runtime(project_detail) - |> assign_conversation_surface(selected_surface) - |> sync_conversation_subscription(previous_conversation_id) - end - - defp clear_project_conversation(socket) do - socket - |> assign(:repo_intake_surface, empty_conversation_surface()) - |> assign(:conversation_roster, []) - |> assign(:conversation_roster_notice, nil) - |> assign(:conversation_surface, empty_conversation_surface()) - |> assign(:conversation_snapshot, nil) - |> assign(:conversation_events, []) - |> assign(:conversation_runtime, empty_conversation_runtime()) - |> assign(:conversation_last_event_sequence, 0) - |> assign(:conversation_input, "") - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign(:conversation_stream_mode, :idle) - |> assign(:conversation_stream_degraded_reason, nil) - |> assign(:conversation_stream_discontinuity_count, 0) - end - - defp assign_conversation_surface(socket, projection) do - snapshot = Map.get(projection, :snapshot) - recent_events = Map.get(projection, :recent_events, []) - - socket - |> assign(:conversation_surface, projection) - |> assign(:conversation_snapshot, snapshot) - |> assign(:conversation_events, recent_events) - |> assign(:conversation_last_event_sequence, (snapshot && snapshot.last_event_sequence) || 0) - |> assign(:conversation_stream_mode, conversation_stream_mode(projection)) - |> assign(:conversation_stream_degraded_reason, conversation_stream_reason(projection)) - |> assign(:conversation_stream_discontinuity_count, 0) - end - - defp assign_conversation_snapshot(socket, snapshot) do - next_selected_work_item_id = - socket.assigns.selected_work_item_id || - present_optional_string(Map.get(snapshot, :work_item_id)) - - assign_project_conversation( - socket, - socket.assigns.project_detail, - next_selected_work_item_id - ) - end - - defp sync_conversation_subscription(socket, previous_conversation_id) do - current_conversation_id = conversation_id(socket) - - if is_binary(previous_conversation_id) and previous_conversation_id != current_conversation_id do - _ = ConversationPubSub.unsubscribe_conversation(previous_conversation_id) - end - - maybe_subscribe_conversation(socket) - end - - defp maybe_subscribe_conversation(socket) do - if connected?(socket) do - case conversation_id(socket) do - conversation_id when is_binary(conversation_id) -> - case ConversationPubSub.subscribe_conversation(conversation_id) do - :ok -> - socket - |> assign(:conversation_stream_mode, :live) - |> assign(:conversation_stream_degraded_reason, nil) - - {:error, reason} -> - socket - |> assign(:conversation_stream_mode, :degraded) - |> assign(:conversation_stream_degraded_reason, inspect(reason)) - - other -> - socket - |> assign(:conversation_stream_mode, :degraded) - |> assign(:conversation_stream_degraded_reason, inspect(other)) - end - - _other -> - socket - end - else - socket - end - end - - defp maybe_unsubscribe_conversation(socket) do - case conversation_id(socket) do - conversation_id when is_binary(conversation_id) -> - _ = ConversationPubSub.unsubscribe_conversation(conversation_id) - socket - - _other -> - socket - end - end - - defp dispatch_conversation_control(socket, command_type, payload) do - case JidoCode.AgentWorkspace.handle_conversation_command( - conversation_id(socket), - %{type: command_type, payload: payload}, - actor: initiating_actor(socket) - ) do - {:ok, snapshot} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, nil) - |> assign(:conversation_action_feedback_kind, :info) - |> assign_conversation_snapshot(snapshot)} - - {:error, reason} -> - {:noreply, - socket - |> assign(:conversation_action_feedback, %{ - error_type: "project_detail_conversation_control_failed", - detail: "The repository conversation could not be updated (#{inspect(reason)}).", - remediation: "Retry the control action after the active conversation state is available again." - }) - |> assign(:conversation_action_feedback_kind, :error)} - end - end - - defp recover_project_conversation_gap(socket) do - case conversation_id(socket) do - conversation_id when is_binary(conversation_id) -> - case JidoCode.AgentWorkspace.conversation_snapshot(conversation_id) do - {:ok, snapshot} -> - socket - |> assign_conversation_snapshot(snapshot) - |> assign( - :conversation_stream_discontinuity_count, - socket.assigns.conversation_stream_discontinuity_count + 1 - ) - - {:error, reason} -> - socket - |> assign(:conversation_stream_mode, :degraded) - |> assign(:conversation_stream_degraded_reason, inspect(reason)) - end - - _other -> - socket - end - end - - defp append_conversation_event(socket, event) do - updated_events = - socket.assigns.conversation_events - |> Kernel.++([event]) - |> Enum.take(-10) - - socket - |> assign(:conversation_events, updated_events) - |> assign(:conversation_last_event_sequence, map_get(event, :sequence, "sequence")) - |> update(:conversation_surface, &Map.put(&1, :recent_events, updated_events)) - end - - defp sync_conversation_event(socket, event) do - case conversation_id(socket) do - conversation_id when is_binary(conversation_id) -> - case JidoCode.AgentWorkspace.conversation_snapshot(conversation_id) do - {:ok, snapshot} -> - socket - |> assign_conversation_snapshot(snapshot) - |> assign(:conversation_stream_mode, :live) - |> assign(:conversation_stream_degraded_reason, nil) - - {:error, _reason} -> - append_conversation_event(socket, event) - end - - _other -> - append_conversation_event(socket, event) - end - end - - defp conversation_event_applies?(socket, event) do - event_conversation_id = map_get(event, :conversation_id, "conversation_id") - is_binary(event_conversation_id) and event_conversation_id == conversation_id(socket) - end - - defp conversation_notice_visible?(%{notice: notice}) when is_map(notice), do: true - defp conversation_notice_visible?(_surface), do: false - - defp conversation_stream_mode(%{notice: notice}) when is_map(notice), do: :degraded - defp conversation_stream_mode(%{snapshot: nil}), do: :idle - defp conversation_stream_mode(%{snapshot: _snapshot}), do: :live - defp conversation_stream_mode(_surface), do: :idle - - defp conversation_stream_reason(%{notice: notice}) when is_map(notice), do: notice.detail - defp conversation_stream_reason(_surface), do: nil - - defp conversation_id(%{assigns: %{conversation_surface: %{conversation: %{} = conversation}}}) do - Map.get(conversation, :id) - end - - defp conversation_id(_socket), do: nil - - defp conversation_input_command(socket, input, params) do - payload = - %{} - |> Map.put("instruction", input) - |> maybe_put("workflow", normalize_optional_string(Map.get(params, "workflow"))) - |> maybe_put("workflow_name", normalize_optional_string(Map.get(params, "workflow_name"))) - - case socket.assigns.conversation_snapshot do - %{active_turn_id: turn_id} = snapshot when is_binary(turn_id) -> - if conversation_awaiting_input?(snapshot) do - %{ - type: "turn.resume", - payload: - payload - |> Map.take(["workflow", "workflow_name"]) - |> Map.put("turn_id", turn_id) - |> Map.put("response", input) - } - else - %{type: "turn.submit", payload: payload} - end - - _other -> - %{type: "turn.submit", payload: payload} - end - end - - defp active_child_work_id(nil), do: nil - defp active_child_work_id(snapshot), do: snapshot.active_child_work_id - - defp conversation_active_turn?(%{active_turn_id: turn_id}) when is_binary(turn_id), do: true - defp conversation_active_turn?(_snapshot), do: false - - defp conversation_paused?(%{status: :paused}), do: true - defp conversation_paused?(_snapshot), do: false - - defp conversation_turn_state(%{active_turn: %{state: state}}), do: state - defp conversation_turn_state(_snapshot), do: "none" - - defp conversation_work_resolution_action(%{conversation: %{work_resolution: %{} = work_resolution}}) do - Map.get(work_resolution, "action", "repo_scoped") - end - - defp conversation_work_resolution_action(_surface), do: "repo_scoped" - - defp conversation_work_resolution_detail(%{conversation: %{work_resolution: %{} = work_resolution}}) do - Map.get(work_resolution, "detail") - end - - defp conversation_work_resolution_detail(_surface), do: nil - - defp conversation_workbench_path(%{id: project_id}) when is_binary(project_id) do - ManagedRepoRoutes.workbench_path(%{"project_id" => project_id}) - end - - defp conversation_workbench_path(_project_detail), do: nil - - defp repo_intake_summary(%{} = surface) do - repo_intake_handoff_detail(surface) || - conversation_surface_summary(surface) || - "Repo intake is available from this managed-repository route." - end - - defp repo_intake_summary(_surface), - do: "Repo intake is available from this managed-repository route." - - defp repo_intake_handoff_work_item_id(%{} = surface) do - surface - |> Map.get(:conversation) - |> Kernel.||(%{}) - |> Map.get(:intake_handoff) - |> Kernel.||(%{}) - |> Map.get("work_item_id") - |> present_optional_string() - end - - defp repo_intake_handoff_work_item_id(_surface), do: nil - - defp repo_intake_handoff_detail(%{} = surface) do - handoff = - surface - |> Map.get(:conversation) - |> Kernel.||(%{}) - |> Map.get(:intake_handoff) - |> Kernel.||(%{}) - - case handoff do - %{"handoff_kind" => "repo_intake_to_work_item", "work_item_id" => work_item_id} - when is_binary(work_item_id) -> - "Repo intake handed off to governed WorkItem #{work_item_id} and preserved the intake continuity here." - - _other -> - nil - end - end - - defp repo_intake_handoff_detail(_surface), do: nil - - defp conversation_surface_summary(%{conversation: %{} = conversation} = surface) do - normalize_optional_string(conversation_work_resolution_detail(surface)) || - normalize_optional_string(Map.get(conversation, :objective)) || - normalize_optional_string(Map.get(conversation, :title)) || - case Map.get(surface, :work_item) do - %{} = work_item -> - "Governed conversation is attached to #{Map.get(work_item, :summary) || Map.get(work_item, "summary") || "the selected work item"}." - - _other -> - "Conversation detail is available on this route." - end - end - - defp conversation_surface_summary(%{work_item: %{} = work_item}) do - "Governed work item #{Map.get(work_item, :summary) || Map.get(work_item, "summary") || Map.get(work_item, :id)} is ready for conversation detail." - end - - defp conversation_surface_summary(_surface), do: nil - - defp conversation_latest_activity_label(%{} = surface) do - surface - |> Map.get(:conversation) - |> Kernel.||(%{}) - |> Map.get(:last_activity_at) - |> format_activity_time() - |> Kernel.||( - surface - |> Map.get(:work_item) - |> Kernel.||(%{}) - |> Map.get(:updated_at) - |> format_activity_time() - ) - end - - defp conversation_latest_activity_label(_surface), do: nil - - defp conversation_runtime_notice_visible?(%{notice: %{} = _notice}), do: true - defp conversation_runtime_notice_visible?(_runtime), do: false - - defp conversation_runtime_preserves_state?(%{status: :blocked}, %{snapshot: %{} = _snapshot}), do: true - defp conversation_runtime_preserves_state?(%{status: :blocked}, %{work_item: %{} = _work_item}), do: true - defp conversation_runtime_preserves_state?(_runtime, _surface), do: false - - defp conversation_runtime_status_label(%{status: :ready}), do: "Ready" - defp conversation_runtime_status_label(%{status: :blocked}), do: "Blocked" - defp conversation_runtime_status_label(_runtime), do: "Idle" - - defp conversation_runtime_status_class(%{status: :ready}), do: "badge-success" - defp conversation_runtime_status_class(%{status: :blocked}), do: "badge-error" - defp conversation_runtime_status_class(_runtime), do: "badge-ghost" - - defp conversation_runtime_llm_label(%{llm_selection: %{model_spec: model_spec}}) - when is_binary(model_spec), - do: model_spec - - defp conversation_runtime_llm_label(_runtime), do: "No provider/model selected" - - defp conversation_runtime_source_label(%{llm_selection: %{source: source}}) when is_atom(source) do - case source do - :explicit -> "Explicit override" - :conversation -> "Conversation metadata" - :repo_default -> "Managed repo default" - :system_default -> "System default" - other -> other |> Atom.to_string() |> String.replace("_", " ") |> String.capitalize() - end - end - - defp conversation_runtime_source_label(_runtime), do: "Unavailable" - - defp conversation_runtime_workspace_label(%{workspace_path: path}) when is_binary(path), do: path - defp conversation_runtime_workspace_label(_runtime), do: ProjectWorkspaceBindingNotice.missing_path_label() - - defp conversation_continuity_detail(:live, 0), do: "Live events are arriving without detected gaps." - - defp conversation_continuity_detail(:live, discontinuity_count) when discontinuity_count > 0 do - "Live delivery recovered from #{discontinuity_count} continuity gap(s)." - end - - defp conversation_continuity_detail(:degraded, discontinuity_count) when discontinuity_count > 0 do - "Showing the durable snapshot after #{discontinuity_count} continuity gap(s)." - end - - defp conversation_continuity_detail(:degraded, _discontinuity_count) do - "Showing the durable snapshot while live delivery is unavailable." - end - - defp conversation_continuity_detail(_stream_mode, _discontinuity_count) do - "Conversation continuity metadata will appear once a route-owned transcript is available." - end - - defp conversation_turn_summary(snapshot) do - cond do - conversation_awaiting_input?(snapshot) -> - "Clarification required" - - conversation_active_turn?(snapshot) -> - "Turn in progress" - - conversation_paused?(snapshot) -> - "Conversation paused" - - true -> - "No active turn" - end - end - - defp conversation_sequence_summary(last_event_sequence, discontinuity_count) - when is_integer(last_event_sequence) do - "Sequence #{last_event_sequence}. Continuity gaps #{discontinuity_count}." - end - - defp conversation_sequence_summary(_last_event_sequence, discontinuity_count) do - "Sequence unavailable. Continuity gaps #{discontinuity_count}." - end - - defp conversation_roster_selected?(selected_work_item_id, %{} = entry) when is_binary(selected_work_item_id) do - entry_work_item_id = - entry - |> Map.get(:work_item) - |> Kernel.||(%{}) - |> Map.get(:id) - |> present_optional_string() - - entry_work_item_id == selected_work_item_id - end - - defp conversation_roster_selected?(_selected_work_item_id, _entry), do: false - - defp selected_conversation_title(selected_work_item_id, _surface) when is_binary(selected_work_item_id), - do: "Governed conversation detail" - - defp selected_conversation_title(_selected_work_item_id, _surface), do: "Repo intake detail" - - defp selected_conversation_summary(selected_work_item_id, %{} = surface) - when is_binary(selected_work_item_id) do - case Map.get(surface, :work_item) do - %{} = work_item -> - "Active or historical governed conversation detail for #{Map.get(work_item, :summary) || Map.get(work_item, "summary") || selected_work_item_id}." - - _other -> - "Governed conversation detail stays attached to the selected work item." - end - end - - defp selected_conversation_summary(_selected_work_item_id, %{} = surface) do - repo_intake_summary(surface) - end - - defp selected_conversation_summary(_selected_work_item_id, _surface), - do: "Repo intake detail stays on this managed-repository route." - - defp format_activity_time(%DateTime{} = datetime) do - Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC") - end - - defp format_activity_time(value) when is_binary(value) do - case DateTime.from_iso8601(value) do - {:ok, datetime, _offset} -> format_activity_time(datetime) - _other -> nil - end - end - - defp format_activity_time(_value), do: nil - - defp conversation_awaiting_input?(%{active_turn: %{state: :awaiting_input}}), do: true - - defp conversation_awaiting_input?(%{ - shared_context: %{"pending_clarification" => %{} = _pending_clarification} - }), - do: true - - defp conversation_awaiting_input?(_snapshot), do: false - - defp conversation_pending_clarification(%{ - shared_context: %{"pending_clarification" => %{} = pending_clarification} - }), - do: pending_clarification - - defp conversation_pending_clarification(_snapshot), do: nil - - defp conversation_clarification_prompt(snapshot) do - snapshot - |> conversation_pending_clarification() - |> case do - %{"prompt" => %{"prompt" => prompt}} -> prompt - %{"prompt" => %{"details" => %{"prompt" => prompt}}} -> prompt - %{"prompt" => prompt} when is_binary(prompt) -> prompt - _other -> nil - end - end - - defp conversation_latest_progress(%{active_child_work: %{result: %{} = result}}) do - case result do - %{"latest_progress" => %{} = latest_progress} -> latest_progress - _other -> nil - end - end - - defp conversation_latest_progress(_snapshot), do: nil - - defp conversation_stdout_preview(%{active_child_work: %{result: %{"stdout" => stdout}}}) - when is_list(stdout), - do: Enum.take(stdout, -4) - - defp conversation_stdout_preview(_snapshot), do: [] - - defp conversation_event_label(event) do - case map_get(event, :name, "name", "") do - "conversation.message_added" -> - "input" - - "conversation.status_changed" -> - "status" - - "turn.awaiting_input" -> - "clarification" - - "turn.delta" -> - "progress" - - "tool.progress" -> - "progress" - - "tool.stdout" -> - "tool output" - - "tool.failed" -> - "failure" - - "tool.cancel_failed" -> - "failure" - - "" -> - "event" - - name when is_binary(name) -> - cond do - String.starts_with?(name, "tool.") -> "tool" - String.starts_with?(name, "turn.") -> "turn" - true -> name |> String.split(".", parts: 2) |> List.first() - end - - _other -> - "event" - end - end - - defp conversation_event_tone(event) do - case map_get(event, :name, "name", "") do - "conversation.message_added" -> - :input - - "conversation.status_changed" -> - :status - - "turn.awaiting_input" -> - :warning - - "turn.completed" -> - :success - - "turn.cancelled" -> - :warning - - "turn.cancelling" -> - :warning - - "turn.delta" -> - :progress - - "tool.progress" -> - :progress - - "tool.stdout" -> - :tool - - "tool.started" -> - :tool - - "tool.completed" -> - :success - - "tool.cancelled" -> - :warning - - "tool.failed" -> - :error - - "tool.cancel_failed" -> - :error - - name when is_binary(name) -> - if String.starts_with?(name, "turn."), do: :turn, else: :neutral - - _other -> - :neutral - end - end - - defp conversation_event_title(event) do - case map_get(event, :name, "name") do - "conversation.message_added" -> - payload = conversation_event_payload(event) - command_payload = map_get(payload, :payload, "payload", %{}) - - map_get(command_payload, :instruction, "instruction") || - map_get(command_payload, :response, "response") || - map_get(command_payload, :reason, "reason") || - "Recorded repository conversation input." - - "conversation.status_changed" -> - "Conversation status is now #{map_get(conversation_event_payload(event), :status, "status") || "active"}." - - "turn.intent_announced" -> - map_get(conversation_event_payload(event), :text, "text") || "Intent announced." - - "turn.queued" -> - "Queued a new repository work turn." - - "turn.started" -> - "Started the active repository turn." - - "turn.awaiting_input" -> - map_get(conversation_event_payload(event), :prompt, "prompt") || - "Waiting for clarification before continuing." - - "turn.delta" -> - map_get(conversation_event_payload(event), :text, "text") || - "Streaming turn update received." - - "turn.cancelling" -> - "Stopping the active repository turn." - - "turn.completed" -> - "The active repository turn completed." - - "turn.cancelled" -> - "The active repository turn was cancelled." - - "tool.started" -> - "Started a child tool execution." - - "tool.progress" -> - map_get(conversation_event_payload(event), :summary, "summary") || - "Progress update received." - - "tool.stdout" -> - map_get(conversation_event_payload(event), :text, "text") || - "Tool output received." - - "tool.needs_input" -> - map_get(conversation_event_payload(event), :prompt, "prompt") || - "The active tool requested more input." - - "tool.completed" -> - conversation_result_summary(event) || "The active tool completed." - - "tool.cancelled" -> - conversation_result_summary(event) || "The active tool cancelled cleanly." - - "tool.failed" -> - conversation_error_detail(event) || "The active tool failed." - - "tool.cancel_failed" -> - conversation_error_detail(event) || "The active tool failed while cancelling." - - other when is_binary(other) -> - other - - _other -> - "Conversation event" - end - end - - defp conversation_event_excerpt(event) do - actor_id = - event - |> map_get(:actor, "actor", %{}) - |> map_get(:id, "id") - - tool_call_id = map_get(event, :tool_call_id, "tool_call_id") - payload = conversation_event_payload(event) - - %{} - |> maybe_put("actor", actor_id) - |> maybe_put("tool_call_id", tool_call_id) - |> maybe_put("summary", map_get(payload, :summary, "summary")) - |> maybe_put("prompt", map_get(payload, :prompt, "prompt")) - |> maybe_put("error", conversation_error_detail(event)) - |> maybe_put( - "text", - map_get(payload, :text, "text") || map_get(payload, :chunk, "chunk") - ) - |> case do - empty when empty == %{} -> nil - details -> inspect(details, pretty: false) - end - end - - defp conversation_event_payload(event), do: map_get(event, :payload, "payload", %{}) - - defp conversation_result_summary(event) do - result = - event - |> conversation_event_payload() - |> map_get(:result, "result", %{}) - - map_get(result, :summary, "summary") || map_get(result, :reason, "reason") - end - - defp conversation_error_detail(event) do - error = - event - |> conversation_event_payload() - |> map_get(:error, "error", %{}) - - map_get(error, :detail, "detail") || map_get(error, :error_type, "error_type") - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp resolve_selected_work_item_id(selected_work_item_id, repo_intake_surface, roster_entries) do - selected_work_item_id || - repo_intake_surface - |> case do - %{} = intake_surface -> - intake_surface - |> Map.get(:conversation) - |> Kernel.||(%{}) - |> Map.get(:work_item_id) - - _other -> - nil - end - |> present_optional_string() || - roster_entries - |> List.first() - |> case do - %{} = entry -> - entry - |> Map.get(:work_item) - |> Kernel.||(%{}) - |> Map.get(:id) - |> present_optional_string() - - _other -> - nil - end - end - - defp project_detail_conversation_path(project_detail, return_to_path, work_item_id) do - project_detail_section_path( - project_detail, - return_to_path, - section: :conversations, - work_item_id: work_item_id, - anchor: "project-detail-conversation-panel" - ) - end - - defp current_project_detail_path(assigns) do - project_detail_section_path( - Map.get(assigns, :project_detail), - Map.get(assigns, :return_to_path), - subject: Map.get(assigns, :selected_detail_subject, :readiness), - section: Map.get(assigns, :selected_detail_section, :overview), - work_item_id: Map.get(assigns, :selected_work_item_id) - ) - end - - defp project_detail_section_path(project_detail, return_to_path, opts) do - project_id = - project_detail - |> Map.get(:id) - |> present_optional_string() - - section = - opts - |> Keyword.get(:section, :overview) - |> normalize_detail_section() - - subject = - opts - |> Keyword.get(:subject, detail_subject_for_section(section)) - |> normalize_detail_subject() - - work_item_id = - opts - |> Keyword.get(:work_item_id) - |> present_optional_string() - - anchor = - opts - |> Keyword.get(:anchor) - |> present_optional_string() - - query = - %{} - |> maybe_put("return_to", normalized_return_to_param(return_to_path)) - |> maybe_put("subject", detail_subject_param(subject)) - |> maybe_put("section", detail_section_param(section)) - |> maybe_put("work_item_id", work_item_id) - - query_suffix = - case query do - empty when empty == %{} -> "" - params -> "?" <> URI.encode_query(params) - end - - anchor_suffix = - case anchor do - nil -> "" - value -> "##{value}" - end - - case project_id do - nil -> "/repos" - id -> "/repos/#{id}#{query_suffix}#{anchor_suffix}" - end - end - - defp detail_section_param(section), do: Atom.to_string(section) - defp detail_subject_param(subject), do: Atom.to_string(subject) - - defp normalize_detail_selection(params) when is_map(params) do - case Map.get(params, "section") |> normalize_detail_section_param() do - nil -> - subject = - params - |> Map.get("subject") - |> normalize_detail_subject() - - {subject, default_detail_section_for_subject(subject)} - - section -> - {detail_subject_for_section(section), section} - end - end - - defp normalize_detail_selection(_params), do: {:readiness, :overview} - - defp normalize_detail_subject(subject) when is_atom(subject) and subject in @detail_subject_order, - do: subject - - defp normalize_detail_subject(subject) when is_binary(subject) do - subject - |> normalize_optional_string() - |> case do - "readiness" -> :readiness - "work" -> :work - "knowledge" -> :knowledge - _other -> :readiness - end - end - - defp normalize_detail_subject(_subject), do: :readiness - - defp normalize_detail_section(section) when is_atom(section) and section in @detail_sections, - do: section - - defp normalize_detail_section(section) when is_binary(section) do - normalize_detail_section_param(section) || :overview - end - - defp normalize_detail_section(_section), do: :overview - - defp normalize_detail_section_param(section) when is_binary(section) do - section - |> normalize_optional_string() - |> case do - "overview" -> :overview - "conversations" -> :conversations - "semantic" -> :semantic - "memory" -> :memory - "workflows" -> :workflows - _other -> nil - end - end - - defp normalize_detail_section_param(_section), do: nil - - defp default_detail_section_for_subject(subject) do - detail_subject_sections() - |> Map.fetch!(subject) - |> List.first() - end - - defp detail_subject_for_section(section) do - detail_subject_sections() - |> Enum.find_value(:readiness, fn {subject, sections} -> - if section in sections, do: subject, else: nil - end) - end - - defp normalized_return_to_param(value), do: present_optional_string(value) - - defp format_time(nil), do: "n/a" - - defp format_time(%DateTime{} = dt) do - Calendar.strftime(dt, "%H:%M:%S") - end - - defp format_time(_value), do: "n/a" - - attr(:feedback, :map, default: nil) - attr(:dom_prefix, :string, required: true) - - defp workflow_launch_feedback(assigns) do - ~H""" -
- <%= case @feedback.status do %> - <% :ok -> %> -

- Run: {@feedback.run.run_id} -

- <.link - id={"#{@dom_prefix}-run-link"} - class="link link-primary text-xs" - href={@feedback.run.detail_path} - > - Open run detail - - <% :error -> %> -

- Typed kickoff error: {@feedback.error.error_type} -

-

- {@feedback.error.detail} -

-

- {@feedback.error.remediation} -

- <% end %> -
- """ - end - - defp put_workflow_launch_state(socket, workflow_name, kickoff_result) do - state_value = - case kickoff_result do - {:ok, kickoff_run} -> - %{status: :ok, run: kickoff_run} - - {:error, kickoff_error} -> - %{status: :error, error: kickoff_error} - end - - update(socket, :workflow_launch_states, &Map.put(&1, workflow_name, state_value)) - end - - defp workflow_launch_feedback(states, workflow_name) when is_map(states) do - states - |> Map.get(normalize_workflow_name(workflow_name)) - end - - defp workflow_launch_feedback(_states, _workflow_name), do: nil - - defp project_detail_overview_props(assigns) do - project_detail = Map.get(assigns, :project_detail, %{}) - supported_workflows = Map.get(assigns, :supported_workflows, []) - - %{ - githubFullName: - project_detail - |> map_get(:github_full_name, "github_full_name") - |> normalize_optional_string() || "unknown/repository", - projectName: - project_detail - |> map_get(:name, "name") - |> normalize_optional_string() || "Unnamed project", - defaultBranch: - project_detail - |> map_get(:default_branch, "default_branch") - |> normalize_optional_string() || "main", - managedRepoId: - project_detail - |> map_get(:managed_repo_id, "managed_repo_id") - |> normalize_optional_string(), - launchReady: project_ready_for_launch?(project_detail), - launchSummary: project_launch_summary(project_detail), - workflowCards: - Enum.map(supported_workflows, fn workflow -> - workflow_feedback = - workflow_launch_feedback(Map.get(assigns, :workflow_launch_states, %{}), workflow.name) - - %{ - id: workflow_dom_id(workflow.name), - label: workflow.label, - name: workflow.name, - launchable: project_ready_for_launch?(project_detail), - feedbackStatus: workflow_feedback_status(workflow_feedback), - feedbackMessage: workflow_feedback_message(workflow_feedback) - } - end) - } - end - - defp project_launch_summary(project_detail) do - if project_ready_for_launch?(project_detail) do - "Managed-repository launch defaults are ready for builtin workflow kickoff." - else - readiness = project_readiness(project_detail) - - readiness - |> map_get(:detail, "detail") - |> normalize_optional_string() || - "Managed-repository launch controls remain blocked until workspace readiness is restored." - end - end - - defp workflow_feedback_status(%{status: :ok}), do: "ok" - defp workflow_feedback_status(%{status: :error}), do: "error" - defp workflow_feedback_status(_feedback), do: nil - - defp workflow_feedback_message(%{status: :ok, run: run}) do - run - |> map_get(:run_id, "run_id") - |> normalize_optional_string() - |> case do - nil -> "Workflow kickoff succeeded." - run_id -> "Latest kickoff run: #{run_id}" - end - end - - defp workflow_feedback_message(%{status: :error, error: error}) do - error - |> map_get(:detail, "detail") - |> normalize_optional_string() || "Workflow kickoff failed." - end - - defp workflow_feedback_message(_feedback), do: nil - - defp project_detail_semantic_explorer_props(assigns) do - inspection = Map.get(assigns, :semantic_inspection) || %{} - graph = Map.get(inspection, :graph, %{}) - - %{ - managedRepoId: Map.get(inspection, :managed_repo_id), - graph: %{ - state: graph |> Map.get(:state, :unavailable) |> to_string(), - ready: Map.get(graph, :ready?, false), - stale: Map.get(graph, :stale?, false), - degraded: Map.get(graph, :degraded?, false), - importedRevision: Map.get(graph, :imported_revision), - currentRevision: Map.get(graph, :current_revision) - }, - summaryCards: [ - %{ - id: "modules", - label: "Modules", - count: semantic_group_count(Map.get(inspection, :summary), :modules), - detail: "Bounded module summaries from the managed repository semantic graph." - }, - %{ - id: "functions", - label: "Functions", - count: semantic_result_count(Map.get(inspection, :functions)), - detail: "Function summaries stay bounded to product-authored semantic projections." - }, - %{ - id: "runtime_patterns", - label: "Runtime patterns", - count: semantic_result_count(Map.get(inspection, :runtime_patterns)), - detail: "Runtime patterns remain explainable without exposing raw graph internals." - }, - %{ - id: "impact", - label: "Impact", - count: semantic_result_count(Map.get(inspection, :impact)), - detail: "Impact relationships stay repo-scoped and recovery-aware." - } - ], - modules: - Enum.map(semantic_items(Map.get(inspection, :modules)), fn item -> - %{ - moduleName: Map.get(item, :module_name), - moduleIri: Map.get(item, :module_iri) - } - end), - functions: - Enum.map(semantic_items(Map.get(inspection, :functions)), fn item -> - %{ - moduleName: Map.get(item, :module_name), - functionName: Map.get(item, :function_name), - arity: Map.get(item, :arity) - } - end), - runtimePatterns: - Enum.map(semantic_items(Map.get(inspection, :runtime_patterns)), fn item -> - %{ - patternName: Map.get(item, :pattern_name), - patternIri: Map.get(item, :pattern_iri) - } - end), - impact: - Enum.map(semantic_items(Map.get(inspection, :impact)), fn item -> - %{ - predicateName: Map.get(item, :predicate_name), - sourceIri: Map.get(item, :source_iri), - targetIri: Map.get(item, :target_iri) - } - end), - recovery: %{ - available: semantic_recovery_available?(inspection), - label: semantic_recovery_label(inspection) - } - } - end - - defp semantic_notice_visible?(%{notice: notice}) when is_map(notice), do: true - defp semantic_notice_visible?(_inspection), do: false - - defp memory_notice_visible?(%{notice: notice}) when is_map(notice), do: true - defp memory_notice_visible?(_inspection), do: false - - defp semantic_notice_kind(%{notice_kind: notice_kind}) when is_atom(notice_kind), do: notice_kind - defp semantic_notice_kind(_inspection), do: :warning - - defp semantic_recovery_available?(%{recovery: %{available?: true}}), do: true - defp semantic_recovery_available?(_inspection), do: false - - defp semantic_recovery_label(%{recovery: %{label: label}}) when is_binary(label), do: label - defp semantic_recovery_label(_inspection), do: "Recover semantic graph" - - defp memory_recovery_available?(%{recovery: %{available?: true}}), do: true - defp memory_recovery_available?(_inspection), do: false - - defp memory_recovery_label(%{recovery: %{label: label}}) when is_binary(label), do: label - defp memory_recovery_label(_inspection), do: "Recover memory graph" - - defp semantic_group_count(%{groups: groups}, group_key) when is_map(groups) do - groups - |> Map.get(group_key, %{}) - |> Map.get(:count, 0) - end - - defp semantic_group_count(_summary, _group_key), do: 0 - - defp semantic_result_count(%{result_group: result_group}) when is_map(result_group), - do: Map.get(result_group, :count, 0) - - defp semantic_result_count(_projection), do: 0 - - defp semantic_items(%{items: items}) when is_list(items), do: items - defp semantic_items(_projection), do: [] - - defp memory_feedback_kind(%{kind: kind}) when is_atom(kind), do: kind - defp memory_feedback_kind(_feedback), do: :info - - defp memory_group_count(%{groups: groups}, group_key) when is_map(groups) do - groups - |> Map.get(group_key, %{}) - |> Map.get(:count, 0) - end - - defp memory_group_count(_summary, _group_key), do: 0 - - defp memory_items(%{items: items}) when is_list(items), do: items - defp memory_items(_projection), do: [] - - defp memory_projection_count(%{result_group: %{count: count}}) when is_integer(count), do: count - defp memory_projection_count(_projection), do: 0 - - defp memory_item_dom_id(item) when is_map(item) do - Map.get(item, :memory_iri) || - Map.get(item, :resource_iri) || - "memory-item" - |> to_string() - |> Base.url_encode64(padding: false) - end - - defp project_ready_for_launch?(project_detail) do - ProjectDetail.ready_for_execution?(project_detail) - end - - defp project_readiness(project_detail) do - project_detail - |> Map.get(:execution_readiness, %{}) - |> case do - %{} = readiness -> readiness - _other -> %{} - end - end - - defp workflow_dom_id(workflow_name) do - workflow_name - |> normalize_workflow_name() - |> String.replace("_", "-") - end - - defp load_memory_inspection(project_detail, return_to_path) do - ProjectMemoryInspection.load_repo_detail( - project_detail, - return_to_path: return_to_path - ) - end - - defp normalize_workflow_name(workflow_name) do - normalize_optional_string(workflow_name) || "unknown-workflow" - end - - defp initiating_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - %{ - id: - user - |> Map.get(:id) - |> normalize_optional_string() || "unknown", - email: - user - |> Map.get(:email) - |> normalize_optional_string() - } - - _other -> - %{id: "unknown", email: nil} - end - end - - defp normalize_return_to_path(return_to) do - ManagedRepoRoutes.normalize_return_to_path(return_to, @repositories_path) - end - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default - - defp normalize_optional_string(nil), do: nil - defp normalize_optional_string(value) when is_boolean(value), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized_value -> normalized_value - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_optional_string(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_optional_string(_value), do: nil - - defp present_optional_string(value) do - case normalize_optional_string(value) do - "nil" -> nil - normalized -> normalized - end - end + use JidoCodeWeb.ResetLive, title: "Repository Detail" end diff --git a/lib/jido_code_web/live/project_inventory_live.ex b/lib/jido_code_web/live/project_inventory_live.ex index b4135924..384815cd 100644 --- a/lib/jido_code_web/live/project_inventory_live.ex +++ b/lib/jido_code_web/live/project_inventory_live.ex @@ -1,418 +1,3 @@ defmodule JidoCodeWeb.ProjectInventoryLive do - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - use JidoCodeWeb, :live_view - - alias JidoCode.Workbench.Inventory - alias JidoCodeWeb.OperatorShell - - @default_branch_filter_value "all" - @default_filter_values %{ - "search" => "", - "default_branch" => @default_branch_filter_value - } - @filter_query_keys ["search", "default_branch"] - @max_search_query_length 120 - - @impl true - def mount(_params, _session, socket) do - projects = load_projects() - - {:ok, - socket - |> assign(:projects_all, projects) - |> assign(:project_count, 0) - |> assign(:project_total_count, length(projects)) - |> assign(:filter_values, @default_filter_values) - |> assign(:filter_form, to_filter_form(@default_filter_values)) - |> assign(:default_branch_filter_options, default_branch_filter_options(projects)) - |> stream(:projects, [], reset: true)} - end - - @impl true - def handle_params(params, _uri, socket) do - filter_values = normalize_filter_values(params, socket.assigns.projects_all) - {:noreply, apply_filters(socket, filter_values)} - end - - @impl true - def handle_event("apply_filters", %{"filters" => filter_params}, socket) do - filter_values = normalize_filter_values(filter_params, socket.assigns.projects_all) - - {:noreply, - socket - |> apply_filters(filter_values) - |> push_patch(to: projects_path_with_filter_values(filter_values))} - end - - @impl true - def handle_event("apply_filters", _params, socket) do - {:noreply, - socket - |> apply_filters(@default_filter_values) - |> push_patch(to: "/repos")} - end - - @impl true - def render(assigns) do - ~H""" - - <.single_pane_shell - id="project-inventory-shell" - breadcrumbs={project_inventory_breadcrumbs()} - pane={project_inventory_pane()} - > -
- <.form - for={@filter_form} - id="project-inventory-filters-form" - phx-change="apply_filters" - phx-submit="apply_filters" - class="grid gap-3 lg:grid-cols-[minmax(16rem,1fr)_minmax(14rem,1fr)]" - > - <.input - id="project-inventory-filter-search" - field={@filter_form[:search]} - type="text" - label="Search" - placeholder="Repository, name, branch, or repository ID" - /> - <.input - id="project-inventory-filter-default-branch" - field={@filter_form[:default_branch]} - type="select" - label="Default branch" - options={@default_branch_filter_options} - /> - - -
- - Search: {search_chip_label(@filter_values)} - - - Default branch: {default_branch_chip_label(@filter_values)} - -
- -

- Showing {@project_count} of {@project_total_count} repositories. -

-
- -
- - - - - - - - - - - - - - - - - - - - -
RepositoryDisplay nameDefault branchActions
- No repositories match the active search and filters. -
- {project.github_full_name} - {project.name} - {project.default_branch} - - <%= if detail_path = project_detail_path(project, @filter_values) do %> - <.link id={"project-inventory-open-#{project.id}"} class="link link-primary" href={detail_path}> - Open repo - - <% else %> - - Repo detail unavailable - - <% end %> -
-
- - <:footer_actions> - <.link id="project-inventory-reset-filters" patch={~p"/repos"} class="btn btn-sm btn-outline"> - Clear filters - - - - -
- """ - end - - defp project_inventory_breadcrumbs do - [ - OperatorShell.breadcrumb(%{ - id: "project-inventory-breadcrumb-current", - label: "Repositories", - current?: true - }) - ] - end - - defp project_inventory_pane do - OperatorShell.pane(%{ - id: "project-inventory-pane", - title: "Managed repository inventory", - summary: "Search and filter imported repositories, then open repo detail." - }) - end - - defp load_projects do - case Inventory.load() do - {:ok, rows, _warning} -> Enum.map(rows, &to_project_row/1) - {:error, _reason} -> [] - end - end - - defp to_project_row(row) do - project_id = - row - |> map_get(:id, "id") - |> normalize_optional_string() - - project_name = - row - |> map_get(:name, "name") - |> normalize_optional_string() - - github_full_name = - row - |> map_get(:github_full_name, "github_full_name") - |> normalize_optional_string() - - default_branch = - row - |> map_get(:default_branch, "default_branch") - |> normalize_optional_string() || "main" - - %{ - id: project_id || github_full_name || project_name || "unknown-project", - name: project_name || github_full_name || "unknown-project", - github_full_name: github_full_name || project_name || "unknown-project", - default_branch: default_branch - } - end - - defp apply_filters(socket, filter_values) do - filtered_projects = filter_projects(socket.assigns.projects_all, filter_values) - - socket - |> assign(:project_count, length(filtered_projects)) - |> assign(:project_total_count, length(socket.assigns.projects_all)) - |> assign(:filter_values, filter_values) - |> assign(:filter_form, to_filter_form(filter_values)) - |> assign(:default_branch_filter_options, default_branch_filter_options(socket.assigns.projects_all)) - |> stream(:projects, filtered_projects, reset: true) - end - - defp filter_projects(projects, filter_values) do - search_term = - filter_values - |> Map.fetch!("search") - |> String.downcase() - - default_branch_filter = Map.fetch!(filter_values, "default_branch") - - Enum.filter(projects, fn project -> - default_branch_matches?(project, default_branch_filter) and - search_matches?(project, search_term) - end) - end - - defp default_branch_matches?(_project, @default_branch_filter_value), do: true - - defp default_branch_matches?(project, default_branch_filter) do - Map.get(project, :default_branch) == default_branch_filter - end - - defp search_matches?(_project, ""), do: true - - defp search_matches?(project, search_term) do - [ - Map.get(project, :github_full_name), - Map.get(project, :name), - Map.get(project, :default_branch), - Map.get(project, :id) - ] - |> Enum.any?(fn value -> - case normalize_optional_string(value) do - nil -> false - normalized_value -> String.contains?(String.downcase(normalized_value), search_term) - end - end) - end - - defp normalize_filter_values(filter_values, projects) when is_map(filter_values) do - %{ - "search" => - filter_values - |> map_get("search", :search) - |> normalize_search_query(), - "default_branch" => - filter_values - |> map_get("default_branch", :default_branch) - |> normalize_default_branch_filter(projects) - } - end - - defp normalize_filter_values(_filter_values, _projects), do: @default_filter_values - - defp normalize_search_query(search_query) do - case normalize_optional_string(search_query) do - nil -> - "" - - normalized_search_query -> - if valid_search_query?(normalized_search_query) do - normalized_search_query - else - "" - end - end - end - - defp valid_search_query?(search_query) do - String.length(search_query) <= @max_search_query_length and - String.match?(search_query, ~r/^[\p{L}\p{N}\s\/._-]+$/u) - end - - defp normalize_default_branch_filter(default_branch_filter, projects) do - candidate_filter_value = - normalize_optional_string(default_branch_filter) || @default_branch_filter_value - - valid_filter_values = - projects - |> Enum.map(&Map.get(&1, :default_branch)) - |> Enum.reject(&is_nil/1) - |> then(&MapSet.new([@default_branch_filter_value | &1])) - - if MapSet.member?(valid_filter_values, candidate_filter_value) do - candidate_filter_value - else - @default_branch_filter_value - end - end - - defp default_branch_filter_options(projects) do - dynamic_options = - projects - |> Enum.map(fn project -> - project - |> Map.get(:default_branch) - |> normalize_optional_string() - end) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - |> Enum.sort_by(&String.downcase/1) - |> Enum.map(fn default_branch -> {default_branch, default_branch} end) - - [{"All branches", @default_branch_filter_value} | dynamic_options] - end - - defp projects_path_with_filter_values(filter_values) do - query_params = - @filter_query_keys - |> Enum.reduce([], fn key, acc -> - value = Map.get(filter_values, key, Map.fetch!(@default_filter_values, key)) - default_value = Map.fetch!(@default_filter_values, key) - - if value == default_value do - acc - else - [{key, value} | acc] - end - end) - |> Enum.reverse() - - case query_params do - [] -> "/repos" - _other -> "/repos?" <> URI.encode_query(query_params) - end - end - - defp project_detail_path(project, filter_values) do - project - |> Map.get(:id) - |> normalize_optional_string() - |> case do - nil -> - nil - - project_id -> - base_path = "/repos/#{URI.encode(project_id)}" - return_to = projects_path_with_filter_values(filter_values) - - if return_to == "/repos" do - base_path - else - "#{base_path}?return_to=#{URI.encode_www_form(return_to)}" - end - end - end - - defp search_chip_label(filter_values) do - case Map.fetch!(filter_values, "search") do - "" -> "Any" - search_query -> search_query - end - end - - defp default_branch_chip_label(filter_values) do - case Map.fetch!(filter_values, "default_branch") do - @default_branch_filter_value -> "All branches" - default_branch -> default_branch - end - end - - defp to_filter_form(filter_values), do: to_form(filter_values, as: :filters) - - defp map_get(map, atom_key, string_key) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> nil - end - end - - defp map_get(_map, _atom_key, _string_key), do: nil - - defp normalize_optional_string(nil), do: nil - defp normalize_optional_string(value) when is_boolean(value), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized_value -> normalized_value - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_optional_string(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_optional_string(_value), do: nil + use JidoCodeWeb.ResetLive, title: "Repositories" end diff --git a/lib/jido_code_web/live/run_detail_live.ex b/lib/jido_code_web/live/run_detail_live.ex index 857b54da..dbfb1c72 100644 --- a/lib/jido_code_web/live/run_detail_live.ex +++ b/lib/jido_code_web/live/run_detail_live.ex @@ -1,3382 +1,3 @@ defmodule JidoCodeWeb.RunDetailLive do - # covers: package.jido_code.primary_implementation_repo - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.repo_posture.governed_run_memory_context_does_not_displace_posture_state - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - # covers: architecture.runtime_service_overlay.runtime_topology_details_remain_opaque_to_product - # covers: architecture.runtime_service_overlay.runtime_narratives_can_coexist_with_bounded_memory_context - # covers: architecture.run_governance.execution_projection_stays_internal_to_canonical_run_model - # covers: architecture.run_governance.run_detail_can_host_bounded_memory_context - # covers: architecture.source_code_graph_product_adoption.governed_surfaces_may_cohost_semantic_cross_links - # covers: architecture.source_code_graph_product_adoption.operator_surfaces_do_not_expose_raw_graph_internals - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - use JidoCodeWeb, :live_view - - alias JidoCode.Control.{Actor, RepoBridge} - alias JidoCode.Governance.{ChangeRequest, Decision, Evidence, RepoPosture} - alias JidoCode.ManagedRepoRoutes - alias JidoCode.MemoryGraph.{FollowUpSurface, GovernedSurfaceContext, OperatorService, ProductService, SurfaceFeedback} - alias JidoCode.Operations.WorkItem - alias JidoCode.Orchestration.{Run, RunPubSub} - alias JidoCode.Workbench.ProjectConversation - alias JidoCodeWeb.OperatorShell - - @run_events_for_refresh MapSet.new([ - "run_started", - "step_started", - "step_completed", - "step_failed", - "approval_requested", - "approval_granted", - "approval_rejected", - "run_completed", - "run_failed", - "run_cancelled" - ]) - @run_event_refresh_delay_ms 50 - @artifact_categories [ - %{id: "logs", label: "Logs"}, - %{id: "diff_summaries", label: "Diff summaries"}, - %{id: "reports", label: "Reports"}, - %{id: "pr_metadata", label: "PR metadata"} - ] - - @impl true - def mount(%{"id" => project_id, "run_id" => run_id} = params, _session, socket) do - return_to_path = - params - |> Map.get("return_to") - |> normalize_return_to_path(project_id) - - socket = - case load_run_state(project_id, run_id, return_to_path) do - {:ok, run_state} -> - socket - |> assign(:project_id, project_id) - |> assign(:run_id, run_id) - |> assign(:return_to_path, return_to_path) - |> assign_run_state(run_state) - |> assign(:memory_action_feedback, nil) - |> assign(:approval_action_error, nil) - |> assign(:retry_action_error, nil) - - {:error, :not_found} -> - assign_missing_run(socket, project_id, run_id, return_to_path) - - {:error, _reason} -> - assign_missing_run(socket, project_id, run_id, return_to_path) - end - - {:ok, maybe_subscribe_run_events(socket)} - end - - @impl true - def handle_info({:run_event, payload}, socket) do - if refresh_for_run_event?(payload, socket) do - Process.send_after(self(), :refresh_run_after_event, @run_event_refresh_delay_ms) - {:noreply, refresh_run_assigns(socket)} - else - {:noreply, socket} - end - end - - @impl true - def handle_info(:refresh_run_after_event, socket), do: {:noreply, refresh_run_assigns(socket)} - - @impl true - def handle_info(_message, socket), do: {:noreply, socket} - - @impl true - def handle_event("approve_run", _params, %{assigns: %{run: %Run{} = run}} = socket) do - case Run.approve(run, %{ - actor: approving_actor(socket), - current_step: "resume_execution" - }) do - {:ok, %Run{} = _approved_run} -> - {:noreply, - socket - |> refresh_run_assigns() - |> assign(:approval_action_error, nil) - |> assign(:retry_action_error, nil)} - - {:error, typed_failure} -> - {:noreply, - socket - |> refresh_run_assigns() - |> assign(:approval_action_error, normalize_approval_action_failure(typed_failure))} - end - end - - @impl true - def handle_event("approve_run", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event("reject_run", params, %{assigns: %{run: %Run{} = run}} = socket) do - rationale = - params - |> map_get(:rationale, "rationale") - |> normalize_optional_string() - - case Run.reject(run, %{ - actor: approving_actor(socket), - rationale: rationale - }) do - {:ok, %Run{} = _rejected_run} -> - {:noreply, - socket - |> refresh_run_assigns() - |> assign(:approval_action_error, nil) - |> assign(:retry_action_error, nil)} - - {:error, typed_failure} -> - {:noreply, - socket - |> refresh_run_assigns() - |> assign(:approval_action_error, normalize_approval_action_failure(typed_failure))} - end - end - - @impl true - def handle_event("reject_run", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event("retry_run", _params, %{assigns: %{run: %Run{} = run}} = socket) do - case Run.retry(run, %{actor: approving_actor(socket)}) do - {:ok, %Run{} = retried_run} -> - {:noreply, - socket - |> assign(:retry_action_error, nil) - |> assign(:approval_action_error, nil) - |> put_flash(:info, "Full-run retry started as #{retried_run.run_id}.") - |> push_navigate( - to: - ManagedRepoRoutes.run_detail_path( - socket.assigns.project_id, - retried_run.run_id, - return_to: socket.assigns.return_to_path - ) - )} - - {:error, typed_failure} -> - {:noreply, - socket - |> refresh_run_assigns() - |> assign(:retry_action_error, normalize_retry_action_failure(typed_failure))} - end - end - - @impl true - def handle_event("retry_run", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event("retry_step", _params, %{assigns: %{run: %Run{} = run}} = socket) do - case Run.retry_step(run, %{actor: approving_actor(socket)}) do - {:ok, %Run{} = retried_run} -> - {:noreply, - socket - |> assign(:retry_action_error, nil) - |> assign(:approval_action_error, nil) - |> put_flash( - :info, - "Step-level retry started at #{retried_run.current_step} as #{retried_run.run_id}." - ) - |> push_navigate( - to: - ManagedRepoRoutes.run_detail_path( - socket.assigns.project_id, - retried_run.run_id, - return_to: socket.assigns.return_to_path - ) - )} - - {:error, typed_failure} -> - {:noreply, - socket - |> refresh_run_assigns() - |> assign(:retry_action_error, normalize_retry_action_failure(typed_failure))} - end - end - - @impl true - def handle_event("retry_step", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event("recover_memory_graph", _params, socket) do - with %{managed_repo_id: managed_repo_id, workspace_path: workspace_path} <- socket.assigns.memory_context, - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id, - workspace_path when is_binary(workspace_path) <- workspace_path do - case ProductService.recover(managed_repo_id, workspace_path) do - {:ok, _recovery_result} -> - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_result( - memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - )} - - {:error, reason, diagnostics} -> - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - reason, - diagnostics, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - )} - end - else - _other -> - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - :memory_governed_scope_unavailable, - nil, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - )} - end - end - - @impl true - def handle_event( - "validate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{memories: projection}, run: %Run{} = run}} = socket - ) do - case OperatorService.validate(projection, memory_iri, memory_operator_opts(socket, run)) do - {:ok, _result} -> - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :validate, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("validate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "invalidate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{memories: projection}, run: %Run{} = run}} = socket - ) do - case OperatorService.invalidate(projection, memory_iri, memory_operator_opts(socket, run)) do - {:ok, _result} -> - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :invalidate, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("invalidate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "promote_memory_follow_up", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{memories: projection}, run: %Run{} = run}} = socket - ) do - case OperatorService.promote_follow_up(projection, memory_iri, memory_operator_opts(socket, run)) do - {:ok, %{result: %{work_item: work_item}, target: :work_item}} -> - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :promote_follow_up, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface", - work_item_id: work_item.id - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("promote_memory_follow_up", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "supersede_memory", - %{"memory_iri" => memory_iri, "decision_id" => decision_id}, - %{assigns: %{memory_context: %{memories: projection}, decisions: decisions, run: %Run{} = run}} = socket - ) do - with %{} = decision <- - Enum.find(decisions, &(normalize_optional_string(map_get(&1, :id, "id")) == decision_id)), - {:ok, _result} <- - OperatorService.supersede_with_governed_decision( - projection, - memory_iri, - decision, - memory_operator_opts(socket, run, decision_id: decision_id) - ) do - refreshed_socket = refresh_run_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :supersede_with_governed_decision, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - )} - else - nil -> - {:noreply, assign_memory_action_error(socket, :governed_decision_not_found)} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("supersede_memory", _params, socket), do: {:noreply, socket} - - @impl true - def render(assigns) do - ~H""" - - <.single_pane_shell - id="run-detail-shell" - breadcrumbs={run_detail_breadcrumbs(assigns)} - pane={run_detail_pane(assigns)} - > -
- <%= if @run do %> -
-

- Run: {@run.run_id} -

-

- Status: {status_label(@run.status)} -

-

- Current step: {current_step_label(@run.current_step)} -

-

- Attempt: {Map.get(@run, :retry_attempt, 1)} -

-

- Retry parent: {Map.get(@run, :retry_of_run_id)} -

-

- Governed stage: {Map.get(@run, :current_stage)} -

-

- Managed repo: {Map.get(@run, :managed_repo_id)} -

-
- - <.vue_surface - id="run-detail-governance-overview-widget" - component="RunGovernanceOverviewWidget" - socket={@socket} - props={run_governance_widget_props(assigns)} - /> - -
-

Governance

- -
-

- Review request: {Map.get(@change_request, :status) |> status_label()} -

-

- {Map.get(@change_request, :summary) |> normalize_optional_string()} -

-
- -
-

Work item

- - <%= if @work_item do %> -
-

- {Map.get(@work_item, :summary)} -

-

- Status: {Map.get(@work_item, :status) |> status_label()} -

-

- Category: {Map.get(@work_item, :category)} -

-
- <% else %> -

- No governed work item is linked to this run yet. -

- <% end %> -
- -
-

Conversation lineage

- - <%= cond do %> - <% @conversation_linkage && @conversation_linkage.conversation -> %> -
-
- <.conversation_role_badge - id="run-detail-conversation-role" - scope={Map.get(@conversation_linkage.conversation, :scope)} - attachment_mode={Map.get(@conversation_linkage.conversation, :attachment_mode)} - work_item_id={Map.get(@conversation_linkage.conversation, :work_item_id)} - /> - <.conversation_status_badge - id="run-detail-conversation-status" - status={Map.get(@conversation_linkage.conversation, :status)} - /> -
-

- Conversation: {@conversation_linkage.conversation.id} -

-

- Work resolution: {conversation_resolution_action(@conversation_linkage.conversation)} -

-

- {conversation_resolution_detail(@conversation_linkage.conversation)} -

-

- Origin workflow: {@conversation_linkage.origin["workflow"]} -

-

- {@conversation_linkage.origin["resolution_reason"]} -

-

- {@conversation_linkage.notice.detail} -

-

- <.conversation_role_badge - id="run-detail-conversation-historical-role" - historical={true} - /> - - Historical origin: {@conversation_linkage.historical_conversation.id} - -

- <.link - id="run-detail-conversation-open-repo" - class="link link-primary text-xs" - navigate={route_for_conversation_linkage(@project_id, @conversation_linkage, @return_to_path)} - > - {@conversation_linkage.action_label} - -
- <% @conversation_linkage && @conversation_linkage.origin -> %> -
- <.conversation_role_badge id="run-detail-conversation-origin-role" historical={true} /> -

- Historical conversation lineage is preserved on this work item. -

-

- Origin workflow: {@conversation_linkage.origin["workflow"]} -

-

- {@conversation_linkage.origin["resolution_reason"]} -

-

- {@conversation_linkage.notice.detail} -

- <.link - id="run-detail-conversation-open-repo" - class="link link-primary text-xs" - navigate={route_for_conversation_linkage(@project_id, @conversation_linkage, @return_to_path)} - > - {@conversation_linkage.action_label} - -
- <% @conversation_linkage && @conversation_linkage.notice -> %> -
-

- Conversation lineage is temporarily unavailable. -

-

- {@conversation_linkage.notice.detail} -

- <.link - id="run-detail-conversation-open-repo" - class="link link-primary text-xs" - navigate={route_for_conversation_linkage(@project_id, @conversation_linkage, @return_to_path)} - > - Open repo detail - -
- <% @work_item -> %> -

- No productive repository conversation is linked to this governed work item yet. -

- <% true -> %> -

- No governed work item is linked to this run yet, so conversation lineage is unavailable. -

- <% end %> -
- -
-

Evidence records

- - <%= if @evidence_records == [] do %> -

- No governed evidence records have been captured yet. -

- <% else %> -
    -
  1. -

    - {Map.get(evidence, :key)} -

    -

    - {Map.get(evidence, :summary)} -

    -
  2. -
- <% end %> -
- -
-

Runtime evidence

- - <%= if @runtime_evidence_summary do %> -
-

- Runtime posture: - - {runtime_evidence_status_label(@runtime_evidence_summary.status)} - -

-

- {@runtime_evidence_summary.summary} -

-

- Delivery path: {humanize_runtime_value(@runtime_evidence_summary.delivery_mode)} -

-

- Runtime reason: {humanize_runtime_value(@runtime_evidence_summary.reason_code)} -

-

- Latest integration signal: {@runtime_evidence_summary.integration_summary} -

-

- Product governance stores bounded runtime evidence here; runtime transport remains opaque. -

-
- <% else %> -

- No bounded runtime evidence has been materialized for this run yet. -

- <% end %> -
- -
-

Decisions

- - <%= if @decisions == [] do %> -

- No governance decisions have been recorded yet. -

- <% else %> -
    -
  1. -

    - {Map.get(decision, :decision) |> status_label()} -

    -

    - {Map.get(decision, :rationale)} -

    -
  2. -
- <% end %> -
- -
-
-

Repository memory context

-

- Memory state: {Map.get(@memory_context.graph, :state, :unavailable)} -

- <.operator_state_notice - :if={@memory_action_feedback} - id="run-detail-memory-action-feedback" - title="Run memory update" - state={@memory_action_feedback} - kind={memory_feedback_kind(@memory_action_feedback)} - compact={true} - /> - <.memory_status_notice - :if={@memory_context.notice} - id="run-detail-memory-context-notice" - title="Run memory status" - state={@memory_context.notice} - kind={Map.get(@memory_context, :notice_kind, :warning)} - recovery={Map.get(@memory_context, :recovery)} - recover_event="recover_memory_graph" - recover_id="run-detail-memory-recover" - /> -
- -
-
-

- Work item history -

-
-

{@memory_context.governed_history.work_item.label}

-

- Memory: {@memory_context.governed_history.work_item.memory_count} | Provenance: {@memory_context.governed_history.work_item.provenance_count} -

-
-
- -
-

- Evidence history -

-
    -
  1. -

    {entry.label}

    -

    - Memory: {entry.memory_count} | Provenance: {entry.provenance_count} -

    -
  2. -
-
- -
-

- Decision history -

-
    -
  1. -

    {entry.label}

    -

    - Memory: {entry.memory_count} | Provenance: {entry.provenance_count} -

    -
  2. -
-
-
- -
-

- Conversation-origin recall -

- - <%= if @memory_context.conversation_recall.items == [] do %> -

- No bounded conversation-origin recall currently points at this governed history. -

- <% else %> -
    -
  1. - <.conversation_origin_card - dom_prefix={"run-detail-conversation-recall-#{index}"} - item={item} - /> -
  2. -
- <% end %> -
- -
-
-

- Work item memory context -

- <.governed_memory_surface - dom_prefix="run-detail-work-item-memory" - context={@memory_context.governed_surfaces.work_item} - /> -
- -
-

- Evidence memory context -

-
    -
  1. - <.governed_memory_surface - dom_prefix={"run-detail-evidence-memory-#{governed_record_dom_token(context.id)}"} - context={context} - /> -
  2. -
-
- -
-

- Decision memory context -

-
    -
  1. - <.governed_memory_surface - dom_prefix={"run-detail-decision-memory-#{governed_record_dom_token(context.id)}"} - context={context} - /> - -
  2. -
-
-
- -
-

- Durable memories -

- - <%= if @memory_context.memories.items == [] do %> -

- No durable memories currently point at this governed history. -

- <% else %> -
    -
  1. -

    - {memory_item_kind(item)}: {memory_item_content(item)} -

    -

    - Freshness: {memory_item_freshness(item)} | Decision status: {memory_item_decision_status(item)} -

    -
    - - - -
    - <.memory_link_groups dom_prefix={"run-detail-memory-#{index}"} item={item} /> -
  2. -
- <% end %> -
- -
-

- Memory-aware follow-up -

-

- {@memory_follow_up_preview.summary} -

-

- Recommended action: {@memory_follow_up_preview.recommended_action_label} | Priority: {@memory_follow_up_preview.priority} | Urgency: {@memory_follow_up_preview.urgency} -

-

- Selected memory kinds: {Enum.join(@memory_follow_up_preview.memory_kinds, ", ")} -

- <.link - :if={@memory_follow_up_preview.route} - id="run-detail-memory-follow-up-preview-route" - class="link link-primary text-xs" - navigate={@memory_follow_up_preview.route} - > - {@memory_follow_up_preview.route_label} - -
- -
-

- Conversation-aware follow-up -

-

- {@conversation_follow_up_preview.summary} -

-

- Recommended action: {@conversation_follow_up_preview.recommended_action_label} | Priority: {@conversation_follow_up_preview.priority} | Urgency: {@conversation_follow_up_preview.urgency} -

-

- Selected bounded recalls: {@conversation_follow_up_preview.selected_count} -

- <.link - :if={@conversation_follow_up_preview.route} - id="run-detail-conversation-follow-up-preview-route" - class="link link-primary text-xs" - navigate={@conversation_follow_up_preview.route} - > - {@conversation_follow_up_preview.route_label} - -
- -
-

- Workflow provenance -

- - <%= if @memory_context.provenance.items == [] do %> -

- No workflow provenance currently points at this governed history. -

- <% else %> -
    -
  1. -

    - {provenance_item_kind(item)}: {provenance_item_label(item)} -

    -

    - Revision: {provenance_item_revision(item)} -

    - <.memory_link_groups dom_prefix={"run-detail-memory-provenance-#{index}"} item={item} /> -
  2. -
- <% end %> -
-
-
- - <%= if @issue_triage_artifacts do %> -
-

Issue triage artifacts

-

- Persistence status: {@issue_triage_artifacts.persistence_status} -

-

- Classification: {@issue_triage_artifacts.classification} -

-

- {@issue_triage_artifacts.research_summary} -

-

- {@issue_triage_artifacts.proposed_response} -

-

- Response post status: {@issue_triage_artifacts.response_post_status} -

-

- Posted comment: - <.link - href={@issue_triage_artifacts.posted_comment_url} - target="_blank" - rel="noopener noreferrer" - class="link link-primary break-all" - > - {@issue_triage_artifacts.posted_comment_url} - -

-

- Posted comment ID: {@issue_triage_artifacts.posted_comment_id} -

-

- Posted at: {@issue_triage_artifacts.response_posted_at} -

-

- Issue reference: {@issue_triage_artifacts.issue_reference} -

-

- Source issue number: {@issue_triage_artifacts.source_issue_number} -

-

- Linked run: {@issue_triage_artifacts.linked_run_id} -

- - <%= if @issue_triage_artifacts.typed_failure do %> -
-

- Typed persistence failure: {@issue_triage_artifacts.typed_failure.error_type} -

-

- {@issue_triage_artifacts.typed_failure.detail} -

-

- {@issue_triage_artifacts.typed_failure.remediation} -

-
- <% end %> - - <%= if @issue_triage_artifacts.response_post_failure do %> -
-

- Typed post failure: {@issue_triage_artifacts.response_post_failure.error_type} -

-

- {@issue_triage_artifacts.response_post_failure.detail} -

-

- {@issue_triage_artifacts.response_post_failure.remediation} -

-
- <% end %> -
- <% end %> - -
-

Run artifacts

-

- Browse persisted artifact records grouped by category. -

- -
-

- {category.label} -

- - <%= if category.entries == [] do %> -

- Missing artifact records for this category. -

- <% else %> -
    -
  1. -

    - Identifier: {entry.identifier} -

    -

    - Source: {entry.source} -

    - <.link - id={"run-detail-artifact-view-#{entry.identifier}"} - href={"#run-detail-artifact-payload-#{entry.identifier}"} - class="link link-primary text-xs" - > - View artifact - -
    -

    {entry.summary}

    -
    {entry.payload}
    -
    -
  2. -
- <% end %> -
-
- - <%= if @failure_context do %> -
-

Failure context

-

- Error type: {@failure_context.error_type} -

-

- Typed reason: {@failure_context.reason_type} -

-

- Last successful step: {@failure_context.last_successful_step} -

-

- Failed step: {@failure_context.failed_step} -

-

- {@failure_context.detail} -

-

- {@failure_context.remediation} -

- - <%= if @failure_context.missing_fields != [] do %> -

- Missing failure context fields: {Enum.join(@failure_context.missing_fields, ", ")} -

- <% end %> -
- <% end %> - - <%= if awaiting_approval?(@run.status) do %> -
-

Approval request payload

-

- Review this context before approving. -

- - <%= if @approval_context do %> -
-

- Diff summary: {@approval_context.diff_summary} -

-

- Test summary: {@approval_context.test_summary} -

-
-

Risk notes

-
    -
  • - {risk_note} -
  • -
-
-
- <% else %> -

- Approval context is unavailable. -

- <% end %> - - <%= if @approval_context_blocker do %> -
-

- {@approval_context_blocker.message} -

-

- {@approval_context_blocker.detail} -

-

- {@approval_context_blocker.remediation} -

-
- <% end %> - -
- - -
- <.input - id="run-detail-reject-rationale" - type="textarea" - name="rationale" - label="Rejection rationale (optional)" - value="" - /> - -
-
- - <%= if @approval_action_error do %> -
-

- Typed action failure: {@approval_action_error.error_type} -

-

- {@approval_action_error.detail} -

-

- {@approval_action_error.remediation} -

-
- <% end %> -
- <% end %> - - <%= if full_run_retry_available?(@run.status) do %> -
-

Retry run

-

- Starts a full-run retry attempt and preserves failure lineage for artifact and reason lookup. -

- - - <%= if @step_retry_state.available do %> -
-

- Restarts retry at contract step {@step_retry_state.retry_step} - while preserving prior failure lineage. -

- -
- <% else %> -
-

- {@step_retry_state.guidance.detail} -

-

- {@step_retry_state.guidance.remediation} -

-
- <% end %> - - <%= if @retry_action_error do %> -
-

- Typed action failure: {@retry_action_error.error_type} -

-

- {@retry_action_error.detail} -

-

- {@retry_action_error.remediation} -

-
- <% end %> -
- <% end %> - - <%= if @retry_lineage_entries != [] do %> -
-

Retry lineage

-
    -
  1. -

    - Prior run: {entry.run_id} -

    -

    - Status: {entry.status} (attempt {entry.retry_attempt}) -

    -

    - Typed reason: {entry.reason_type} -

    -

    - {entry.detail} -

    -

    - Preserved artifact keys: {entry.artifact_count} -

    -
  2. -
-
- <% end %> - -
-

Status timeline

- - <%= if @timeline_entries == [] do %> -

- No status transitions recorded. -

- <% else %> -
    -
  1. -

    - {entry.to_status} -

    -

    - Step: {entry.current_step} -

    -

    - Duration: {entry.duration} -

    -

    - Recorded at: {entry.transitioned_at} -

    - <%= if entry.approval_audit do %> -

    - Approval audit: {entry.approval_audit} -

    - <% end %> -
  2. -
- <% end %> -
- <% else %> -
-

Run not found

-

- Could not find run {@run_id} for this project. -

-
- <% end %> -
- - <:footer_actions> - <.link - id="run-detail-return-link" - class="btn btn-sm btn-outline" - navigate={@return_to_path} - > - Back to {return_to_label(@return_to_path)} - - - -
- """ - end - - defp run_detail_breadcrumbs(assigns) do - broad_parent_path = - ManagedRepoRoutes.repo_detail_parent_return_to(Map.get(assigns, :return_to_path), "/repos") - - [ - OperatorShell.breadcrumb(%{ - id: "run-detail-breadcrumb-parent", - label: return_to_label(broad_parent_path), - navigate: broad_parent_path - }), - run_detail_repo_breadcrumb(assigns, broad_parent_path), - OperatorShell.breadcrumb(%{ - id: "run-detail-breadcrumb-current", - label: run_detail_breadcrumb_label(assigns), - current?: true - }) - ] - |> Enum.reject(&is_nil/1) - end - - defp run_detail_pane(assigns) do - OperatorShell.pane(%{ - id: "run-detail", - title: "Run detail", - summary: run_detail_pane_summary(assigns) - }) - end - - defp run_detail_repo_breadcrumb(assigns, broad_parent_path) do - case normalize_optional_string(Map.get(assigns, :project_id)) do - nil -> - nil - - project_id -> - OperatorShell.breadcrumb(%{ - id: "run-detail-breadcrumb-repo", - label: "Repo detail", - navigate: ManagedRepoRoutes.project_detail_path(project_id, return_to: broad_parent_path) - }) - end - end - - defp run_detail_breadcrumb_label(assigns) do - run_id = - assigns - |> Map.get(:run) - |> case do - %Run{} = run -> map_get(run, :run_id, "run_id") - _other -> Map.get(assigns, :run_id) - end - |> normalize_optional_string() - - if is_binary(run_id), do: "Run #{run_id}", else: "Run detail" - end - - defp run_detail_pane_summary(%{run: %Run{} = run}) do - "Run #{run.run_id} is #{status_label(run.status)} at #{current_step_label(run.current_step)}. Governed evidence, approval context, artifacts, and retry lineage stay grouped on this route." - end - - defp run_detail_pane_summary(_assigns) do - "The requested run could not be found for this project. Use the parent surface or repo detail to continue." - end - - defp assign_missing_run(socket, project_id, run_id, return_to_path) do - socket - |> assign(:project_id, project_id) - |> assign(:run_id, run_id) - |> assign(:return_to_path, return_to_path || normalize_return_to_path(nil, project_id)) - |> assign(:run, nil) - |> assign(:work_item, nil) - |> assign(:conversation_linkage, nil) - |> assign(:evidence_records, []) - |> assign(:change_request, nil) - |> assign(:decisions, []) - |> assign(:timeline_entries, []) - |> assign(:retry_lineage_entries, []) - |> assign(:artifact_categories, default_artifact_categories()) - |> assign(:failure_context, nil) - |> assign(:issue_triage_artifacts, nil) - |> assign(:approval_context, nil) - |> assign(:approval_context_blocker, nil) - |> assign(:runtime_evidence_summary, nil) - |> assign(:memory_context, nil) - |> assign(:conversation_follow_up_preview, nil) - |> assign(:memory_follow_up_preview, nil) - |> assign(:step_retry_state, step_retry_state(nil)) - |> assign(:memory_action_feedback, nil) - |> assign(:approval_action_error, nil) - |> assign(:retry_action_error, nil) - end - - defp timeline_entries(%Run{} = run) do - run - |> workflow_audit_status_transitions() - |> normalize_timeline_entries() - end - - defp timeline_entries(_run), do: [] - - defp assign_run_state( - socket, - %{ - run: run, - work_item: work_item, - conversation_linkage: conversation_linkage, - evidence_records: evidence_records, - change_request: change_request, - decisions: decisions, - repo_posture: repo_posture, - memory_context: memory_context, - conversation_follow_up_preview: conversation_follow_up_preview, - memory_follow_up_preview: memory_follow_up_preview - } - ) do - socket - |> assign(:run, run) - |> assign(:work_item, work_item) - |> assign(:conversation_linkage, conversation_linkage) - |> assign(:evidence_records, evidence_records) - |> assign(:change_request, change_request) - |> assign(:decisions, decisions) - |> assign(:runtime_evidence_summary, runtime_evidence_summary(run, evidence_records, repo_posture)) - |> assign(:memory_context, memory_context) - |> assign(:conversation_follow_up_preview, conversation_follow_up_preview) - |> assign(:memory_follow_up_preview, memory_follow_up_preview) - |> assign(:timeline_entries, timeline_entries(run)) - |> assign(:retry_lineage_entries, retry_lineage_entries(run)) - |> assign(:artifact_categories, artifact_categories(run)) - |> assign(:failure_context, failure_context(run)) - |> assign(:issue_triage_artifacts, issue_triage_artifacts(run)) - |> assign(:approval_context, approval_context(run)) - |> assign(:approval_context_blocker, approval_context_blocker(run)) - |> assign(:step_retry_state, step_retry_state(run)) - end - - defp refresh_run_assigns(%{assigns: %{project_id: project_id, run_id: run_id}} = socket) do - case load_run_state(project_id, run_id, Map.get(socket.assigns, :return_to_path)) do - {:ok, run_state} -> assign_run_state(socket, run_state) - _other -> assign_missing_run(socket, project_id, run_id, Map.get(socket.assigns, :return_to_path)) - end - end - - defp assign_memory_action_error(socket, reason) do - refreshed_socket = refresh_run_assigns(socket) - - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_error( - reason, - graph: memory_context_graph(refreshed_socket), - surface_label: "this governed run surface" - ) - ) - end - - defp memory_context_graph(%{assigns: %{memory_context: %{graph: graph}}}) when is_map(graph), do: graph - defp memory_context_graph(_socket), do: nil - - defp load_run_state(project_id, run_id, return_to_path) do - with {:ok, project_scope} <- RepoBridge.repo_scope(project_id), - {:ok, run} <- load_governed_run(project_scope, run_id) do - evidence_records = load_evidence_records(run) - decisions = load_decisions(run) - work_item = load_work_item(run, evidence_records, decisions) - conversation_linkage = ProjectConversation.load_work_item_linkage(work_item, actor: Actor.operator_actor()) - - managed_repo_id = memory_context_managed_repo_id(project_scope, run) - workspace_path = load_repo_workspace_path(project_scope) - - memory_context = - GovernedSurfaceContext.load_run_detail( - project_scope, - run, - evidence_records, - decisions, - work_item: work_item, - project_id: project_id, - managed_repo_id: managed_repo_id, - workspace_path: workspace_path, - return_to_path: return_to_path - ) - - {:ok, - %{ - run: run, - work_item: work_item, - conversation_linkage: conversation_linkage, - evidence_records: evidence_records, - change_request: load_change_request(run), - decisions: decisions, - repo_posture: load_repo_posture(run), - memory_context: memory_context, - conversation_follow_up_preview: - load_conversation_follow_up_preview( - memory_context, - project_id, - work_item, - return_to_path - ), - memory_follow_up_preview: - load_memory_follow_up_preview( - memory_context, - run, - managed_repo_id, - return_to_path - ) - }} - else - {:error, :governed_run_not_found} -> - {:error, :not_found} - - {:error, :repo_scope_not_found} -> - {:error, :not_found} - - {:error, _reason} = error -> - error - end - end - - defp load_governed_run(project_scope, run_id) do - managed_repo_id = - project_scope - |> map_get(:managed_repo_id, "managed_repo_id") - |> normalize_optional_string() - - normalized_run_id = normalize_optional_string(run_id) - - cond do - is_nil(managed_repo_id) or is_nil(normalized_run_id) -> - {:error, :governed_run_not_found} - - true -> - case Run.get_by_managed_repo_and_run_id(managed_repo_id, normalized_run_id, actor: Actor.operator_actor()) do - {:ok, %Run{} = run} -> {:ok, run} - {:ok, nil} -> {:error, :governed_run_not_found} - {:error, reason} -> {:error, reason} - end - end - end - - defp load_evidence_records(%Run{} = run) do - case Evidence.read( - query: [filter: [run_id: run.id], sort: [recorded_at: :asc]], - actor: Actor.operator_actor() - ) do - {:ok, evidence_records} -> evidence_records - _other -> [] - end - end - - defp load_evidence_records(_run), do: [] - - defp load_work_item(%Run{} = run, evidence_records, decisions) do - work_item_id = - normalize_optional_string(Map.get(run, :work_item_id)) || - work_item_id_from_records(evidence_records) || - work_item_id_from_records(decisions) - - case work_item_id do - work_item_id when is_binary(work_item_id) -> - case WorkItem.read(query: [filter: [id: work_item_id], limit: 1], actor: Actor.operator_actor()) do - {:ok, [work_item | _rest]} -> work_item - _other -> placeholder_work_item(work_item_id) - end - - _other -> - nil - end - end - - defp load_work_item(_run, _evidence_records, _decisions), do: nil - - defp work_item_id_from_records(records) when is_list(records) do - records - |> Enum.find_value(fn record -> - normalize_optional_string(Map.get(record, :work_item_id) || Map.get(record, "work_item_id")) - end) - end - - defp work_item_id_from_records(_records), do: nil - - defp placeholder_work_item(work_item_id) when is_binary(work_item_id) do - %{ - id: work_item_id, - summary: "Governed work item #{work_item_id}", - status: :unknown, - category: "governed_follow_up" - } - end - - defp load_change_request(%Run{} = run) do - case ChangeRequest.read(query: [filter: [run_id: run.id], limit: 1], actor: Actor.operator_actor()) do - {:ok, [change_request | _rest]} -> change_request - _other -> nil - end - end - - defp load_change_request(_run), do: nil - - defp load_decisions(%Run{} = run) do - case Decision.read( - query: [filter: [run_id: run.id], sort: [decided_at: :desc]], - actor: Actor.operator_actor() - ) do - {:ok, decisions} -> decisions - _other -> [] - end - end - - defp load_decisions(_run), do: [] - - defp load_repo_posture(%Run{} = run) do - managed_repo_id = - run - |> map_get(:managed_repo_id, "managed_repo_id") - |> normalize_optional_string() - - if is_nil(managed_repo_id) do - nil - else - case RepoPosture.get_by_managed_repo_id(managed_repo_id, actor: Actor.operator_actor()) do - {:ok, %RepoPosture{} = repo_posture} -> repo_posture - _other -> nil - end - end - end - - defp load_repo_posture(_run), do: nil - - defp load_repo_workspace_path(project_scope) when is_map(project_scope) do - project_scope - |> map_get(:managed_repo, "managed_repo", %{}) - |> map_get(:workspace_settings, "workspace_settings", %{}) - |> map_get(:workspace_path, "workspace_path") - |> normalize_optional_string() - end - - defp load_repo_workspace_path(_project_scope), do: nil - - defp memory_context_managed_repo_id(project_scope, %Run{} = run) do - normalize_optional_string(map_get(run, :managed_repo_id, "managed_repo_id")) || - normalize_optional_string(map_get(project_scope, :managed_repo_id, "managed_repo_id")) || - project_scope - |> map_get(:managed_repo, "managed_repo", %{}) - |> map_get(:id, "id") - |> normalize_optional_string() - end - - defp governed_record_dom_token(value) do - value - |> normalize_optional_string() - |> case do - nil -> "unknown" - normalized -> normalized - end - |> String.replace(~r/[^a-zA-Z0-9_-]/, "-") - end - - attr :dom_prefix, :string, required: true - attr :context, :map, required: true - - defp governed_memory_surface(assigns) do - ~H""" -
-
-
-

{@context.label}

-

- Memory: {@context.memory_count} | Provenance: {@context.provenance_count} -

-
- <.link - :if={@context.route} - id={"#{@dom_prefix}-route"} - class="link link-primary text-xs" - navigate={@context.route} - > - Open governed record - -
- - <%= if @context.memories.items == [] do %> -

- No durable memories currently point at this governed record. -

- <% else %> -
    -
  1. -

    - {memory_item_kind(item)}: {memory_item_content(item)} -

    -

    - Freshness: {memory_item_freshness(item)} | Decision status: {memory_item_decision_status(item)} -

    -
    - - - -
    - <.memory_link_groups dom_prefix={"#{@dom_prefix}-memory-#{index}"} item={item} /> -
  2. -
- <% end %> - - <%= if @context.provenance.items == [] do %> -

- No workflow provenance currently points at this governed record. -

- <% else %> -
    -
  1. -

    - {provenance_item_kind(item)}: {provenance_item_label(item)} -

    -

    - Revision: {provenance_item_revision(item)} -

    - <.memory_link_groups dom_prefix={"#{@dom_prefix}-provenance-#{index}"} item={item} /> -
  2. -
- <% end %> -
- """ - end - - defp decision_memory_iri(items) when is_list(items) do - items - |> Enum.find(&(memory_item_kind(&1) == "Decision")) - |> case do - nil -> nil - item -> memory_item_iri(item) - end - end - - defp decision_memory_iri(_items), do: nil - - defp memory_feedback_kind(%{kind: kind}) when is_atom(kind), do: kind - defp memory_feedback_kind(_feedback), do: :info - - defp memory_item_iri(item) do - present_string(map_get(item, :memory_iri, "memory_iri")) - end - - defp memory_item_kind(item) do - present_string(map_get(item, :memory_kind, "memory_kind")) || - kind_from_resource_iri(memory_item_iri(item)) || - "Memory" - end - - defp memory_item_content(item) do - present_string(map_get(item, :content, "content")) || "bounded durable memory" - end - - defp memory_item_freshness(item) do - map_get(item, :freshness_score, "freshness_score") || "unknown" - end - - defp memory_item_decision_status(item) do - present_string(map_get(item, :decision_status, "decision_status")) || "n/a" - end - - defp provenance_item_kind(item) do - present_string(map_get(item, :provenance_kind, "provenance_kind")) || - kind_from_resource_iri(present_string(map_get(item, :resource_iri, "resource_iri"))) || - "Provenance" - end - - defp provenance_item_label(item) do - present_string(map_get(item, :label, "label")) || - present_string(map_get(item, :content, "content")) || - "bounded provenance" - end - - defp provenance_item_revision(item) do - present_string(map_get(item, :revision_iri, "revision_iri")) || "unknown" - end - - defp kind_from_resource_iri(nil), do: nil - - defp kind_from_resource_iri(value) when is_binary(value) do - value - |> String.split("#") - |> List.last() - |> case do - nil -> nil - fragment -> fragment |> String.split("/") |> List.first() - end - |> present_string() - |> case do - nil -> nil - segment -> segment |> String.replace("-", "_") |> Macro.camelize() - end - end - - defp load_memory_follow_up_preview(memory_context, %Run{} = run, managed_repo_id, return_to_path) - when is_map(memory_context) do - FollowUpSurface.preview( - Map.get(memory_context, :memories), - route: follow_up_preview_route(run, managed_repo_id, return_to_path), - category: "governed_follow_up" - ) - end - - defp load_memory_follow_up_preview(_memory_context, _run, _managed_repo_id, _return_to_path), do: nil - - defp load_conversation_follow_up_preview(memory_context, project_id, work_item, return_to_path) - when is_map(memory_context) do - route = conversation_follow_up_route(project_id, work_item, return_to_path) - - FollowUpSurface.preview( - Map.get(memory_context, :conversation_recall), - route: route, - category: "governed_conversation_follow_up" - ) - end - - defp load_conversation_follow_up_preview(_memory_context, _project_id, _work_item, _return_to_path), do: nil - - defp follow_up_preview_route(%Run{} = run, managed_repo_id, return_to_path) do - managed_repo_id = present_string(managed_repo_id) - run_id = present_string(run.run_id) - - if is_binary(managed_repo_id) and is_binary(run_id) do - ManagedRepoRoutes.run_detail_path( - managed_repo_id, - run_id, - return_to: return_to_path - ) <> "#run-detail-memory-context" - else - nil - end - end - - defp conversation_follow_up_route(project_id, work_item, return_to_path) do - repo_detail_return_to = ManagedRepoRoutes.repo_detail_parent_return_to(return_to_path) - work_item_id = work_item |> map_get(:id, "id") |> present_string() - - if is_binary(project_id) do - ManagedRepoRoutes.project_detail_path( - project_id, - return_to: repo_detail_return_to, - section: "conversations", - work_item_id: work_item_id, - anchor: "project-detail-conversation-panel" - ) - else - nil - end - end - - defp route_for_conversation_linkage(project_id, conversation_linkage, return_to_path) do - project_id = present_string(project_id) - repo_detail_return_to = ManagedRepoRoutes.repo_detail_parent_return_to(return_to_path) - - work_item_id = - conversation_linkage - |> map_get(:work_item, "work_item", %{}) - |> map_get(:id, "id") - |> present_string() - - cond do - is_binary(project_id) and is_binary(work_item_id) -> - ManagedRepoRoutes.project_detail_path( - project_id, - return_to: repo_detail_return_to, - section: "conversations", - work_item_id: work_item_id, - anchor: "project-detail-conversation-panel" - ) - - is_binary(project_id) -> - ManagedRepoRoutes.project_detail_path( - project_id, - return_to: repo_detail_return_to, - section: "conversations", - anchor: "project-detail-conversation-panel" - ) - - true -> - "#run-detail-conversation-linkage" - end - end - - defp return_to_label(path), do: ManagedRepoRoutes.return_to_label(path) - - defp normalize_return_to_path(return_to, project_id) do - ManagedRepoRoutes.normalize_return_to_path( - return_to, - ManagedRepoRoutes.project_detail_path(project_id) - ) - end - - defp present_string(value) do - case normalize_optional_string(value) do - "nil" -> nil - normalized -> normalized - end - end - - defp run_governance_widget_props(assigns) do - %{ - runStatus: - assigns - |> Map.get(:run) - |> map_get(:status, "status") - |> status_label(), - currentStage: - assigns - |> Map.get(:run) - |> map_get(:current_stage, "current_stage") - |> normalize_optional_string(), - changeRequestStatus: - assigns - |> Map.get(:change_request) - |> map_get(:status, "status") - |> normalize_optional_string(), - evidenceCount: length(Map.get(assigns, :evidence_records, [])), - decisionCount: length(Map.get(assigns, :decisions, [])), - runtimeEvidence: run_runtime_evidence_widget(assigns), - evidenceEntries: - Enum.map(Map.get(assigns, :evidence_records, []), fn evidence -> - %{ - key: - evidence - |> map_get(:key, "key") - |> normalize_optional_string() || "unknown", - summary: - evidence - |> map_get(:summary, "summary") - |> normalize_optional_string() || "Evidence summary unavailable." - } - end), - decisionEntries: - Enum.map(Map.get(assigns, :decisions, []), fn decision -> - %{ - decision: - decision - |> map_get(:decision, "decision") - |> status_label(), - rationale: - decision - |> map_get(:rationale, "rationale") - |> normalize_optional_string() - } - end) - } - end - - defp run_runtime_evidence_widget(assigns) do - case Map.get(assigns, :runtime_evidence_summary) do - %{} = runtime_evidence -> - %{ - statusLabel: - runtime_evidence - |> map_get(:status, "status") - |> runtime_evidence_status_label(), - summary: - runtime_evidence - |> map_get(:summary, "summary") - |> normalize_optional_string() || "Runtime evidence summary unavailable.", - deliveryMode: widget_runtime_detail_value(map_get(runtime_evidence, :delivery_mode, "delivery_mode")), - reason: widget_runtime_detail_value(map_get(runtime_evidence, :reason_code, "reason_code")), - integration: - runtime_evidence - |> map_get(:integration_summary, "integration_summary") - |> normalize_optional_string() - } - - _other -> - nil - end - end - - defp widget_runtime_detail_value(value) do - case normalize_optional_string(value) do - nil -> nil - normalized -> humanize_runtime_value(normalized) - end - end - - defp maybe_subscribe_run_events(socket) do - run_id = - socket.assigns - |> Map.get(:run_id) - |> normalize_optional_string() - - if connected?(socket) and run_id do - :ok = RunPubSub.subscribe_run(run_id) - end - - socket - end - - defp refresh_for_run_event?(payload, socket) do - event_name = - payload - |> map_get(:event, "event") - |> normalize_optional_string() - - payload_run_id = - payload - |> map_get(:run_id, "run_id") - |> normalize_optional_string() - - socket_run_id = - socket.assigns - |> Map.get(:run_id) - |> normalize_optional_string() - - MapSet.member?(@run_events_for_refresh, event_name) and - (is_nil(payload_run_id) or payload_run_id == socket_run_id) - end - - defp workflow_audit(%Run{} = run) do - run_metadata = - run - |> Map.get(:run_metadata, %{}) - |> normalize_map() - - run_metadata - |> Map.get("workflow_audit", %{}) - |> normalize_map() - end - - defp workflow_audit(_run), do: %{} - - defp workflow_audit_status_transitions(%Run{} = run) do - workflow_audit = workflow_audit(run) - - case Map.get(workflow_audit, "status_transitions") do - transitions when is_list(transitions) -> - transitions - - _other -> - run - |> Map.get(:run_metadata, %{}) - |> normalize_map() - |> Map.get("status_transitions", []) - end - end - - defp workflow_audit_status_transitions(_run), do: [] - - defp workflow_audit_step_results(%Run{} = run) do - run - |> workflow_audit() - |> Map.get("step_results", %{}) - |> normalize_map() - end - - defp workflow_audit_step_results(_run), do: %{} - - defp workflow_audit_error(%Run{} = run) do - run - |> workflow_audit() - |> Map.get("error", %{}) - |> normalize_map() - end - - defp workflow_audit_error(_run), do: %{} - - defp failure_context(%Run{} = run) do - if failed_status?(Map.get(run, :status)) do - error = workflow_audit_error(run) - - if map_size(error) == 0 do - nil - else - missing_fields = - error - |> map_get(:missing_failure_context_fields, "missing_failure_context_fields", []) - |> normalize_missing_failure_fields() - - %{ - error_type: - error - |> map_get(:error_type, "error_type") - |> normalize_optional_string() || "workflow_run_failed", - reason_type: - error - |> map_get(:reason_type, "reason_type") - |> normalize_optional_string() || "workflow_run_failed", - last_successful_step: - error - |> map_get(:last_successful_step, "last_successful_step") - |> normalize_optional_string() || "unknown", - failed_step: - error - |> map_get(:failed_step, "failed_step") - |> normalize_optional_string() || - (run - |> Map.get(:current_step) - |> normalize_optional_string() || "unknown"), - detail: - error - |> map_get(:detail, "detail") - |> normalize_optional_string() || - "Workflow run failed before full failure context was captured.", - remediation: - error - |> map_get(:remediation, "remediation") - |> normalize_optional_string() || - "Inspect failure artifacts and retry from run detail after resolving the failing step.", - missing_fields: missing_fields - } - end - else - nil - end - end - - defp failure_context(_run), do: nil - - defp issue_triage_artifacts(%Run{} = run) do - workflow_name = - run - |> Map.get(:workflow_name) - |> normalize_optional_string() - - if workflow_name == "issue_triage" do - step_results = - workflow_audit_step_results(run) - - triage_artifact = - step_results - |> map_get(:run_issue_triage, "run_issue_triage", %{}) - |> normalize_map() - - research_artifact = - step_results - |> map_get(:run_issue_research, "run_issue_research", %{}) - |> normalize_map() - - response_artifact = - step_results - |> map_get(:compose_issue_response, "compose_issue_response", %{}) - |> normalize_map() - - artifact_lineage = - step_results - |> map_get(:issue_bot_artifact_lineage, "issue_bot_artifact_lineage", %{}) - |> normalize_map() - - response_post_artifact = - step_results - |> map_get(:post_issue_response, "post_issue_response", %{}) - |> normalize_map() - - if map_size(triage_artifact) == 0 and map_size(research_artifact) == 0 and - map_size(response_artifact) == 0 and map_size(artifact_lineage) == 0 and - map_size(response_post_artifact) == 0 do - nil - else - linked_run = - triage_artifact - |> map_get(:linked_run, "linked_run") - |> normalize_map() - |> case do - linked_run when map_size(linked_run) > 0 -> - linked_run - - _other -> - research_artifact - |> map_get(:linked_run, "linked_run") - |> normalize_map() - |> case do - linked_run when map_size(linked_run) > 0 -> - linked_run - - _other -> - response_artifact - |> map_get(:linked_run, "linked_run") - |> normalize_map() - |> case do - linked_run when map_size(linked_run) > 0 -> - linked_run - - _other -> - artifact_lineage - |> map_get(:linked_run, "linked_run", %{}) - |> normalize_map() - end - end - end - - source_issue = - linked_run - |> map_get(:source_issue, "source_issue") - |> normalize_map() - |> case do - source_issue when map_size(source_issue) > 0 -> - source_issue - - _other -> - run - |> Map.get(:trigger, %{}) - |> map_get(:source_issue, "source_issue", %{}) - |> normalize_map() - end - - typed_failure = - artifact_lineage - |> map_get(:typed_failure, "typed_failure") - |> normalize_map() - |> case do - typed_failure when map_size(typed_failure) > 0 -> - %{ - error_type: - typed_failure - |> map_get(:error_type, "error_type") - |> normalize_optional_string() || "issue_triage_artifact_persistence_failed", - detail: - typed_failure - |> map_get(:detail, "detail") - |> normalize_optional_string() || "Issue triage artifact persistence failed.", - remediation: - typed_failure - |> map_get(:remediation, "remediation") - |> normalize_optional_string() || "Retry artifact persistence from run detail." - } - - _other -> - nil - end - - response_post_failure = - response_post_artifact - |> map_get(:typed_failure, "typed_failure") - |> normalize_map() - |> case do - typed_failure when map_size(typed_failure) > 0 -> - %{ - error_type: - typed_failure - |> map_get(:error_type, "error_type") - |> normalize_optional_string() || "issue_triage_response_post_failed", - detail: - typed_failure - |> map_get(:detail, "detail") - |> normalize_optional_string() || "Issue response post failed.", - remediation: - typed_failure - |> map_get(:remediation, "remediation") - |> normalize_optional_string() || - "Retry issue response posting from run detail." - } - - _other -> - nil - end - - %{ - classification: - triage_artifact - |> map_get(:classification, "classification") - |> normalize_optional_string() || "unavailable", - research_summary: - research_artifact - |> map_get(:summary, "summary") - |> normalize_optional_string() || "Research summary is unavailable.", - proposed_response: - response_artifact - |> map_get(:proposed_response, "proposed_response") - |> normalize_optional_string() || "Proposed response draft is unavailable.", - response_post_status: - response_post_artifact - |> map_get(:status, "status") - |> normalize_optional_string() || "not_attempted", - posted_comment_url: - response_post_artifact - |> map_get(:comment_url, "comment_url") - |> normalize_optional_string(), - posted_comment_id: - response_post_artifact - |> map_get(:comment_id, "comment_id") - |> normalize_optional_integer(), - response_posted_at: - response_post_artifact - |> map_get( - :posted_at, - "posted_at", - map_get(response_post_artifact, :attempted_at, "attempted_at") - ) - |> normalize_optional_string(), - issue_reference: - linked_run - |> map_get(:issue_reference, "issue_reference") - |> normalize_optional_string() || - run - |> Map.get(:inputs, %{}) - |> map_get(:issue_reference, "issue_reference") - |> normalize_optional_string(), - source_issue_number: - source_issue - |> map_get(:number, "number") - |> normalize_optional_integer(), - linked_run_id: - linked_run - |> map_get(:run_id, "run_id") - |> normalize_optional_string() || - run - |> Map.get(:run_id) - |> normalize_optional_string(), - persistence_status: - artifact_lineage - |> map_get(:status, "status") - |> normalize_optional_string() || "unknown", - typed_failure: typed_failure, - response_post_failure: response_post_failure - } - end - else - nil - end - end - - defp issue_triage_artifacts(_run), do: nil - - defp runtime_evidence_summary(run, evidence_records, %RepoPosture{} = repo_posture) do - posture_metadata = normalize_map(repo_posture.posture_metadata) - - runtime_state = - posture_metadata - |> Map.get("runtime_service_evidence_state", posture_metadata["runtime_capability_state"] || %{}) - |> normalize_map() - - runtime_delivery_evidence = - evidence_records - |> List.wrap() - |> Enum.find(&((Map.get(&1, :key) || Map.get(&1, "key")) == "runtime_service_delivery")) - - runtime_delivery = - runtime_delivery_evidence - |> map_get(:evidence_details, "evidence_details", %{}) - |> normalize_map() - - latest_invocation = - runtime_state - |> get_in(["integration_outcomes", "latest_invocation"]) - |> normalize_map() - - summary = - posture_metadata["runtime_service_evidence_summary"] || - posture_metadata["runtime_capability_summary"] || - Map.get(runtime_delivery_evidence || %{}, :summary) || - Map.get(run, :summary) - - if is_binary(summary) and String.trim(summary) != "" do - %{ - status: - Map.get(runtime_state, "status") || - posture_metadata - |> Map.get("runtime_capability_state", %{}) - |> normalize_map() - |> Map.get("status") || "absent", - summary: summary, - delivery_mode: - Map.get(runtime_delivery, "delivery_mode") || - get_in(runtime_state, ["runtime_delivery", "delivery_mode"]), - reason_code: - Map.get(runtime_delivery, "reason_code") || - get_in(runtime_state, ["runtime_delivery", "reason_code"]), - integration_summary: - Map.get(latest_invocation, "summary") || - case {Map.get(latest_invocation, "provider"), Map.get(latest_invocation, "operation_id")} do - {provider, operation_id} when is_binary(provider) and is_binary(operation_id) -> - "#{provider}:#{operation_id}" - - _other -> - nil - end - } - end - end - - defp runtime_evidence_summary(_run, _evidence_records, _repo_posture), do: nil - - defp runtime_evidence_badge_class("blocked"), do: "badge badge-error" - defp runtime_evidence_badge_class("degraded"), do: "badge badge-warning" - defp runtime_evidence_badge_class("available"), do: "badge badge-success" - defp runtime_evidence_badge_class(_status), do: "badge badge-outline" - - defp runtime_evidence_status_label(status) do - case normalize_optional_string(status) do - "blocked" -> "blocked" - "degraded" -> "review required" - "available" -> "stable" - nil -> "unknown" - other -> other - end - end - - defp humanize_runtime_value(value) do - value - |> normalize_optional_string() - |> case do - nil -> "unknown" - normalized -> normalized |> String.replace("_", " ") - end - end - - defp default_artifact_categories do - Enum.map(@artifact_categories, fn category -> - Map.put(category, :entries, []) - end) - end - - defp artifact_categories(%Run{} = run) do - step_results = workflow_audit_step_results(run) - - artifact_nodes = collect_artifact_nodes(step_results) - - Enum.map(@artifact_categories, fn category -> - entries = - artifact_nodes - |> Enum.filter(&artifact_matches_category?(&1, category.id)) - |> Enum.map(&artifact_entry(category.id, &1)) - |> Enum.uniq_by(& &1.source) - |> Enum.sort_by(& &1.source) - - Map.put(category, :entries, entries) - end) - end - - defp artifact_categories(_run), do: default_artifact_categories() - - defp collect_artifact_nodes(%{} = value), do: collect_artifact_nodes(value, []) - - defp collect_artifact_nodes(%{} = value, path) when is_list(path) do - Enum.flat_map(value, fn {key, nested_value} -> - path_segment = artifact_path_segment(key) - next_path = path ++ [path_segment] - - [%{path: next_path, value: nested_value} | collect_artifact_nodes(nested_value, next_path)] - end) - end - - defp collect_artifact_nodes(value, path) when is_list(value) and is_list(path) do - value - |> Enum.with_index(1) - |> Enum.flat_map(fn {nested_value, index} -> - collect_artifact_nodes(nested_value, path ++ ["item_#{index}"]) - end) - end - - defp collect_artifact_nodes(_value, _path), do: [] - - defp artifact_matches_category?(artifact_node, category_id) when is_map(artifact_node) do - artifact_key = - artifact_node - |> Map.get(:path, []) - |> List.last() - |> normalize_optional_string() - |> case do - nil -> "" - value -> String.downcase(value) - end - - artifact_value = Map.get(artifact_node, :value) - - case category_id do - "logs" -> - artifact_key in ["run_logs", "logs", "log", "stdout", "stderr"] or - String.ends_with?(artifact_key, "_logs") or String.ends_with?(artifact_key, "_log") - - "diff_summaries" -> - artifact_key == "diff_summary" or String.ends_with?(artifact_key, "_diff_summary") - - "reports" -> - artifact_key == "report" or artifact_key == "failure_report" or - String.ends_with?(artifact_key, "_report") - - "pr_metadata" -> - artifact_key in ["pull_request", "pr_metadata", "pr"] or - String.starts_with?(artifact_key, "pr_") or String.ends_with?(artifact_key, "_pr") or - pr_metadata_map?(artifact_value) - - _other -> - false - end - end - - defp artifact_matches_category?(_artifact_node, _category_id), do: false - - defp pr_metadata_map?(%{} = artifact_value) do - artifact_value - |> Map.keys() - |> Enum.map(fn key -> - key - |> normalize_optional_string() - |> case do - nil -> nil - value -> String.downcase(value) - end - end) - |> Enum.reject(&is_nil/1) - |> Enum.any?(fn key -> - key in ["pr_url", "pr_number", "pr_title", "pull_request_url", "pull_request_number"] - end) - end - - defp pr_metadata_map?(_artifact_value), do: false - - defp artifact_entry(category_id, artifact_node) - when is_binary(category_id) and is_map(artifact_node) do - source_path = - artifact_node - |> Map.get(:path, []) - |> Enum.map(&artifact_path_segment/1) - |> Enum.join(".") - - artifact_value = Map.get(artifact_node, :value) - - %{ - identifier: artifact_identifier(category_id, source_path), - source: source_path, - summary: artifact_summary(artifact_value), - payload: artifact_payload(artifact_value) - } - end - - defp artifact_entry(category_id, _artifact_node) do - %{ - identifier: artifact_identifier(category_id, "artifact"), - source: "artifact", - summary: "Artifact payload unavailable.", - payload: "Artifact payload unavailable." - } - end - - defp artifact_identifier(category_id, source_path) - when is_binary(category_id) and is_binary(source_path) do - [category_id, source_path] - |> Enum.join("-") - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/, "-") - |> String.trim("-") - |> case do - "" -> "artifact" - identifier -> identifier - end - end - - defp artifact_identifier(_category_id, _source_path), do: "artifact" - - defp artifact_summary(%{} = value), do: "Map artifact (#{map_size(value)} keys)" - defp artifact_summary(value) when is_list(value), do: "List artifact (#{length(value)} items)" - - defp artifact_summary(value) when is_binary(value) do - trimmed_value = String.trim(value) - - if String.length(trimmed_value) > 96 do - String.slice(trimmed_value, 0, 96) <> "..." - else - trimmed_value - end - end - - defp artifact_summary(value), do: inspect(value) - - defp artifact_payload(value) do - inspect(value, pretty: true, limit: :infinity, printable_limit: :infinity, width: 120) - end - - defp artifact_path_segment(segment) do - segment - |> normalize_optional_string() - |> case do - nil -> inspect(segment) - normalized_segment -> normalized_segment - end - end - - defp approval_context(%Run{} = run) do - step_results = workflow_audit_step_results(run) - - context = - step_results - |> map_get(:approval_context, "approval_context") - |> normalize_map() - - diff_summary = - context - |> map_get(:diff_summary, "diff_summary") - |> normalize_optional_string() - - test_summary = - context - |> map_get(:test_summary, "test_summary") - |> normalize_optional_string() - - risk_notes = - context - |> map_get(:risk_notes, "risk_notes") - |> normalize_risk_notes() - - case {diff_summary, test_summary, risk_notes} do - {nil, nil, []} -> - nil - - _other -> - %{ - diff_summary: diff_summary || "Diff summary unavailable.", - test_summary: test_summary || "Test summary unavailable.", - risk_notes: - if(risk_notes == [], - do: ["Risk notes unavailable. Review changes carefully before approving."], - else: risk_notes - ) - } - end - end - - defp approval_context(_run), do: nil - - defp approval_context_blocker(%Run{} = run) do - diagnostics = - run - |> workflow_audit_error() - |> Map.get("approval_context_diagnostics", []) - |> normalize_diagnostics() - - diagnostics - |> List.last() - |> normalize_approval_context_diagnostic() - end - - defp approval_context_blocker(_run), do: nil - - defp retry_lineage_entries(%Run{} = run) do - run - |> Map.get(:retry_lineage, []) - |> normalize_retry_lineage_entries() - end - - defp retry_lineage_entries(_run), do: [] - - defp normalize_retry_lineage_entries(entries) when is_list(entries) do - Enum.map(entries, fn entry -> - typed_failure = - entry - |> map_get(:typed_failure, "typed_failure", %{}) - |> normalize_map() - - failure_artifacts = - entry - |> map_get(:failure_artifacts, "failure_artifacts", %{}) - |> normalize_map() - - %{ - run_id: - entry - |> map_get(:run_id, "run_id") - |> normalize_optional_string() || "unknown", - status: - entry - |> map_get(:status, "status") - |> normalize_optional_string() || "unknown", - retry_attempt: - entry - |> map_get(:retry_attempt, "retry_attempt") - |> normalize_optional_integer() || 1, - reason_type: - typed_failure - |> map_get(:reason_type, "reason_type") - |> normalize_optional_string() || "unknown", - detail: - typed_failure - |> map_get(:detail, "detail") - |> normalize_optional_string() || "Prior failure details were not captured.", - artifact_count: map_size(failure_artifacts) - } - end) - end - - defp normalize_retry_lineage_entries(_entries), do: [] - - defp step_retry_state(%Run{} = run) do - case Run.step_retry_contract(run) do - {:ok, step_retry_contract} -> - %{ - available: true, - retry_step: - step_retry_contract - |> map_get(:retry_step, "retry_step") - |> normalize_optional_string(), - guidance: nil - } - - {:error, typed_failure} -> - %{ - available: false, - retry_step: nil, - guidance: normalize_retry_action_failure(typed_failure) - } - end - end - - defp step_retry_state(_run) do - %{ - available: false, - retry_step: nil, - guidance: nil - } - end - - defp full_run_retry_available?(status) when is_atom(status), do: status in [:failed, :cancelled] - - defp full_run_retry_available?(status) when is_binary(status) do - case String.trim(status) do - "failed" -> true - "cancelled" -> true - _other -> false - end - end - - defp full_run_retry_available?(_status), do: false - - defp failed_status?(status) when is_atom(status), do: status == :failed - - defp failed_status?(status) when is_binary(status) do - String.trim(status) == "failed" - end - - defp failed_status?(_status), do: false - - defp awaiting_approval?(status) when is_atom(status), do: status == :awaiting_approval - - defp awaiting_approval?(status) when is_binary(status), - do: String.trim(status) == "awaiting_approval" - - defp awaiting_approval?(_status), do: false - - defp normalize_timeline_entries(entries) when is_list(entries) do - normalized_entries = - Enum.map(entries, fn entry -> - transitioned_at = - entry - |> map_get(:transitioned_at, "transitioned_at") - |> normalize_transitioned_at_datetime() - - %{ - to_status: - entry - |> map_get(:to_status, "to_status") - |> normalize_optional_string() || "unknown", - current_step: - entry - |> map_get(:current_step, "current_step") - |> normalize_optional_string() || "unknown", - transitioned_at: format_transitioned_at(transitioned_at), - transitioned_at_datetime: transitioned_at, - approval_audit: normalize_timeline_approval_audit(entry) - } - end) - - next_entries = Enum.drop(normalized_entries, 1) ++ [nil] - - normalized_entries - |> Enum.zip(next_entries) - |> Enum.map(fn {entry, next_entry} -> - duration = - timeline_duration( - Map.get(entry, :transitioned_at_datetime), - next_entry && Map.get(next_entry, :transitioned_at_datetime) - ) - - entry - |> Map.put(:duration, duration) - |> Map.delete(:transitioned_at_datetime) - end) - end - - defp normalize_timeline_entries(_entries), do: [] - - defp normalize_approval_context_diagnostic(%{} = diagnostic) do - message = - diagnostic - |> map_get(:message, "message") - |> normalize_optional_string() - - detail = - diagnostic - |> map_get(:detail, "detail") - |> normalize_optional_string() - - remediation = - diagnostic - |> map_get(:remediation, "remediation") - |> normalize_optional_string() - - if is_nil(message) and is_nil(detail) and is_nil(remediation) do - nil - else - %{ - message: message || "Approval context generation failed.", - detail: detail || "Approval payload generation did not produce complete context.", - remediation: - remediation || - "Regenerate approval payload data with diff, test, and risk summaries before retrying." - } - end - end - - defp normalize_approval_context_diagnostic(_diagnostic), do: nil - - defp normalize_approval_action_failure(typed_failure) when is_map(typed_failure) do - error_type = - typed_failure - |> map_get(:error_type, "error_type") - |> normalize_optional_string() - - detail = - typed_failure - |> map_get(:detail, "detail") - |> normalize_optional_string() - - remediation = - typed_failure - |> map_get(:remediation, "remediation") - |> normalize_optional_string() - - %{ - error_type: error_type || "workflow_run_approval_action_failed", - detail: detail || "Approval action failed and run remains blocked.", - remediation: remediation || "Review run state and retry from run detail." - } - end - - defp normalize_approval_action_failure(_typed_failure) do - %{ - error_type: "workflow_run_approval_action_failed", - detail: "Approval action failed and run remains blocked.", - remediation: "Review run state and retry from run detail." - } - end - - defp normalize_retry_action_failure(typed_failure) when is_map(typed_failure) do - error_type = - typed_failure - |> map_get(:error_type, "error_type") - |> normalize_optional_string() - - detail = - typed_failure - |> map_get(:detail, "detail") - |> normalize_optional_string() - - remediation = - typed_failure - |> map_get(:remediation, "remediation") - |> normalize_optional_string() - - %{ - error_type: error_type || "workflow_run_retry_action_failed", - detail: detail || "Retry action failed and no new attempt was created.", - remediation: remediation || "Review workflow retry policy and retry from run detail." - } - end - - defp normalize_retry_action_failure(_typed_failure) do - %{ - error_type: "workflow_run_retry_action_failed", - detail: "Retry action failed and no new attempt was created.", - remediation: "Review workflow retry policy and retry from run detail." - } - end - - defp conversation_resolution_action(%{} = conversation) do - conversation - |> Map.get(:work_resolution, %{}) - |> conversation_resolution_value(:action) - end - - defp conversation_resolution_action(_conversation), do: nil - - defp conversation_resolution_detail(%{} = conversation) do - conversation - |> Map.get(:work_resolution, %{}) - |> conversation_resolution_value(:detail) - end - - defp conversation_resolution_detail(_conversation), do: nil - - defp conversation_resolution_value(%{} = resolution, key) when is_atom(key) do - Map.get(resolution, key) || Map.get(resolution, Atom.to_string(key)) - end - - defp conversation_resolution_value(_resolution, _key), do: nil - - defp status_label(status) do - status - |> normalize_optional_string() - |> case do - nil -> "unknown" - normalized_status -> normalized_status - end - end - - defp current_step_label(current_step) do - current_step - |> normalize_optional_string() - |> case do - nil -> "unknown" - normalized_step -> normalized_step - end - end - - defp normalize_transitioned_at_datetime(%DateTime{} = transitioned_at) do - DateTime.truncate(transitioned_at, :second) - end - - defp normalize_transitioned_at_datetime(transitioned_at) when is_binary(transitioned_at) do - case DateTime.from_iso8601(transitioned_at) do - {:ok, parsed_transitioned_at, _offset} -> DateTime.truncate(parsed_transitioned_at, :second) - _other -> nil - end - end - - defp normalize_transitioned_at_datetime(_transitioned_at), do: nil - - defp format_transitioned_at(%DateTime{} = transitioned_at) do - DateTime.to_iso8601(transitioned_at) - end - - defp format_transitioned_at(transitioned_at) when is_binary(transitioned_at) do - case DateTime.from_iso8601(transitioned_at) do - {:ok, parsed_transitioned_at, _offset} -> DateTime.to_iso8601(parsed_transitioned_at) - _other -> transitioned_at - end - end - - defp format_transitioned_at(_transitioned_at), do: "unknown" - - defp timeline_duration(%DateTime{} = started_at, %DateTime{} = completed_at) do - case DateTime.diff(completed_at, started_at, :second) do - seconds when is_integer(seconds) and seconds >= 0 -> - format_duration_seconds(seconds) - - _other -> - "unknown" - end - end - - defp timeline_duration(_started_at, _completed_at), do: "unknown" - - defp format_duration_seconds(seconds) when seconds < 60, do: "#{seconds}s" - - defp format_duration_seconds(seconds) when seconds < 3_600 do - "#{div(seconds, 60)}m #{rem(seconds, 60)}s" - end - - defp format_duration_seconds(seconds) do - hours = div(seconds, 3_600) - minutes = div(rem(seconds, 3_600), 60) - remaining_seconds = rem(seconds, 60) - - "#{hours}h #{minutes}m #{remaining_seconds}s" - end - - defp normalize_timeline_approval_audit(entry) when is_map(entry) do - decision = - entry - |> map_get(:metadata, "metadata", %{}) - |> map_get(:approval_decision, "approval_decision", %{}) - |> map_get(:decision, "decision") - |> normalize_optional_string() - - actor = - entry - |> map_get(:metadata, "metadata", %{}) - |> map_get(:approval_decision, "approval_decision", %{}) - |> map_get(:actor, "actor", %{}) - - actor_id = actor |> map_get(:id, "id") |> normalize_optional_string() - actor_email = actor |> map_get(:email, "email") |> normalize_optional_string() - - timestamp = - entry - |> map_get(:metadata, "metadata", %{}) - |> map_get(:approval_decision, "approval_decision", %{}) - |> map_get(:timestamp, "timestamp") - |> normalize_optional_string() - - rationale = - entry - |> map_get(:metadata, "metadata", %{}) - |> map_get(:approval_decision, "approval_decision", %{}) - |> map_get(:rationale, "rationale") - |> normalize_optional_string() - - actor_label = actor_email || actor_id - - parts = - [ - if(decision, do: "decision=#{decision}"), - if(actor_label, do: "actor=#{actor_label}"), - if(timestamp, do: "at=#{format_transitioned_at(timestamp)}"), - if(rationale, do: "rationale=#{rationale}") - ] - |> Enum.reject(&is_nil/1) - - case parts do - [] -> nil - audit_parts -> Enum.join(audit_parts, " ") - end - end - - defp normalize_timeline_approval_audit(_entry), do: nil - - defp approving_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - Actor.operator_actor(%{ - "id" => user |> Map.get(:id) |> normalize_optional_string() || "unknown", - "email" => user |> Map.get(:email) |> normalize_optional_string() - }) - - _other -> - Actor.operator_actor(%{"id" => "unknown", "email" => nil}) - end - end - - defp memory_operator_opts(socket, %Run{} = run, extra_opts \\ []) do - actor = approving_actor(socket) - - [ - actor: actor, - workspace_path: memory_operator_workspace_path(socket), - revision: memory_operator_revision(socket), - run_id: map_get(run, :run_id, "run_id"), - work_item_id: map_get(run, :work_item_id, "work_item_id") - ] ++ extra_opts - end - - defp memory_operator_workspace_path(socket) do - socket.assigns - |> Map.get(:memory_context, %{}) - |> Map.get(:workspace_path) - end - - defp memory_operator_revision(socket) do - socket.assigns - |> Map.get(:memory_context, %{}) - |> Map.get(:graph, %{}) - |> Map.get(:current_revision) - end - - defp normalize_map(%{} = map), do: map - defp normalize_map(_value), do: %{} - - defp normalize_diagnostics(diagnostics) when is_list(diagnostics) do - Enum.filter(diagnostics, &is_map/1) - end - - defp normalize_diagnostics(_diagnostics), do: [] - - defp normalize_risk_notes(value) when is_list(value) do - value - |> Enum.map(&normalize_optional_string/1) - |> Enum.reject(&is_nil/1) - end - - defp normalize_risk_notes(value) do - value - |> normalize_optional_string() - |> case do - nil -> [] - risk_note -> [risk_note] - end - end - - defp normalize_missing_failure_fields(value) when is_list(value) do - value - |> Enum.map(&normalize_optional_string/1) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - defp normalize_missing_failure_fields(value) when is_binary(value) do - value - |> String.split(",") - |> Enum.map(&normalize_optional_string/1) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - defp normalize_missing_failure_fields(_value), do: [] - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default - - defp normalize_optional_string(nil), do: nil - defp normalize_optional_string(value) when is_boolean(value), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized_value -> normalized_value - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(%Ash.CiString{} = value), - do: value |> to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_optional_string(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_optional_string(_value), do: nil - - defp normalize_optional_integer(value) when is_integer(value), do: value - - defp normalize_optional_integer(value) when is_binary(value) do - case Integer.parse(String.trim(value)) do - {parsed, ""} -> parsed - _other -> nil - end - end - - defp normalize_optional_integer(_value), do: nil + use JidoCodeWeb.ResetLive, title: "Run Detail" end diff --git a/lib/jido_code_web/live/settings_live.ex b/lib/jido_code_web/live/settings_live.ex index 3ef604bf..8e030a0c 100644 --- a/lib/jido_code_web/live/settings_live.ex +++ b/lib/jido_code_web/live/settings_live.ex @@ -1,1774 +1,3 @@ defmodule JidoCodeWeb.SettingsLive do - # covers: architecture.policy_layers.operator_surfaces_propagate_current_actor_for_repo_mutations - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.settings_routes_keep_repo_import_liveview_owned - # covers: auth.operator_settings.sections_separated - # covers: auth.operator_settings.broker_trust_configuration_ui - # covers: auth.operator_settings.github_service_validation_feedback - # covers: auth.operator_settings.integration_boundary_visible - # covers: auth.operator_settings.hidden_during_bootstrap_entry - # covers: architecture.factory_control_plane.settings_github_add_uses_canonical_repo_import - use JidoCodeWeb, :live_view - - alias JidoCode.Accounts.SecurityTokens - alias JidoCode.Control.Actor - alias JidoCode.GitHub.Repo - alias JidoCode.Security.SecretRefs - alias JidoCode.Setup.ProjectImport - alias JidoCodeWeb.OperatorAuthSettings - alias JidoCodeWeb.OperatorShell - alias JidoCodeWeb.Security.UiRedaction - - @secret_scope_options [ - {"Instance", "instance"}, - {"Repository", "project"}, - {"Integration", "integration"} - ] - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:show_add_modal, false) - |> assign(:form, nil) - |> assign(:repo_save_error, nil) - |> assign(:repo_count, 0) - |> assign(:enabled_repo_count, 0) - |> assign(:operator_auth_allowlist_options, OperatorAuthSettings.allowlist_options()) - |> assign(:provider_login_cards, []) - |> assign(:github_service_report, %{checked_at: nil, status: :not_configured, paths: []}) - |> assign(:github_service_secret_refs, []) - |> assign(:security_tokens, []) - |> assign(:security_api_keys, []) - |> assign(:security_audit_events, []) - |> assign(:security_revocation_error, nil) - |> assign(:security_status_error, nil) - |> assign(:security_secret_refs, []) - |> assign(:security_secret_error, nil) - |> assign(:security_secret_form, empty_security_secret_form()) - |> assign(:security_secret_lifecycle_audits, []) - |> assign(:security_secret_audit_error, nil) - |> assign(:security_provider_rotation_error, nil) - |> assign(:security_provider_rotation_report, nil) - |> assign(:security_provider_rotation_form, empty_security_provider_rotation_form()) - |> assign(:secret_scope_options, @secret_scope_options) - |> assign(:provider_rotation_options, SecretRefs.provider_rotation_options()) - |> stream(:repos, [], reset: true) - |> load_repos() - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - tab = Map.get(params, "tab", "github") - - socket = - socket - |> assign(:active_tab, tab) - |> maybe_load_settings_tab(tab) - - {:noreply, socket} - end - - @impl true - def render(assigns) do - ~H""" - - <.subject_tree_shell - id="settings-shell" - breadcrumbs={settings_breadcrumbs(assigns)} - parent_subjects={[]} - child_subjects={settings_nav_items(assigns)} - child_nav_id="settings-nav" - child_nav_label="Settings sections" - child_nav_heading="Settings" - child_nav_summary="Choose the operator setting you want to inspect or update." - sidebar_id="settings-sidebar" - content_id="settings-content" - > - <.subject_pane pane={settings_selected_pane(assigns)}> -
- <.vue_surface - id="settings-overview-widget" - component="SettingsOverviewWidget" - socket={@socket} - props={ - %{ - activeTab: @active_tab, - cards: settings_overview_cards(assigns), - activeTabSummary: settings_active_tab_summary(assigns), - openAddRepoVisible: @active_tab == "github" - } - } - events={%{"openAddRepo" => "open_add_modal"}} - /> - - <%= case @active_tab do %> - <% "github" -> %> - <.github_tab repos={@streams.repos} show_add_modal={@show_add_modal} form={@form} /> - <% "agents" -> %> - <.agents_tab /> - <% "account" -> %> - <.account_tab /> - <% "auth" -> %> - <.auth_integrations_tab - provider_login_cards={@provider_login_cards} - github_service_report={@github_service_report} - github_service_secret_refs={@github_service_secret_refs} - operator_auth_allowlist_options={@operator_auth_allowlist_options} - /> - <% "security" -> %> - <.security_tab - security_tokens={@security_tokens} - security_api_keys={@security_api_keys} - security_audit_events={@security_audit_events} - security_revocation_error={@security_revocation_error} - security_status_error={@security_status_error} - security_secret_refs={@security_secret_refs} - security_secret_error={@security_secret_error} - security_secret_form={@security_secret_form} - security_secret_lifecycle_audits={@security_secret_lifecycle_audits} - security_secret_audit_error={@security_secret_audit_error} - secret_scope_options={@secret_scope_options} - security_provider_rotation_error={@security_provider_rotation_error} - security_provider_rotation_report={@security_provider_rotation_report} - security_provider_rotation_form={@security_provider_rotation_form} - provider_rotation_options={@provider_rotation_options} - /> - <% _ -> %> - <.github_tab repos={@streams.repos} show_add_modal={@show_add_modal} form={@form} /> - <% end %> -
- - <:footer_actions :if={@active_tab == "github"}> - - - - <:footer_actions :if={@active_tab == "auth"}> - - - - - -
-
-
-

Add GitHub Repository

- -
- <.form for={@form} phx-change="validate" phx-submit="save_repo" class="mt-4 space-y-4"> -
- {@repo_save_error} -
- <.input - field={@form[:owner]} - type="text" - label="Owner" - placeholder="e.g., agentjido" - /> - <.input - field={@form[:name]} - type="text" - label="Repository Name" - placeholder="e.g., jido" - /> -

- This imports the repository into the managed-repository control plane. Webhook - secrets remain managed in Security settings via encrypted SecretRef entries. -

-
- - -
- -
-
-
- """ - end - - defp github_tab(assigns) do - ~H""" -
-
-

GitHub Repositories

-

- Manage repositories connected to your agent workflows. -

-
- -
-
-
-
- <.icon name="hero-folder" class="w-6 h-6 text-base-content/50" /> -
-

{repo.full_name}

- <% settings_summary = repo_settings_summary(repo.settings) %> -

- {settings_summary.text} -

-

- {settings_summary.alert_message} -

-
-
- -
- - -
-
-
- -
- <.icon name="hero-inbox" class="w-12 h-12 mx-auto text-base-content/30 mb-3" /> -

No repositories configured yet.

-

- Click "Add Repository" to import your first GitHub repository into the managed - control plane. -

-
-
-
- """ - end - - defp agents_tab(assigns) do - ~H""" -
-
-

Agent Configuration

-

- Configure AI agents and their behaviors -

-
- -
- <.icon name="hero-cpu-chip" class="w-12 h-12 mx-auto text-base-content/30 mb-3" /> -

Agent settings coming soon.

-

- This section will allow you to configure agent behaviors and preferences. -

-
-
- """ - end - - defp account_tab(assigns) do - ~H""" -
-
-

Account Settings

-

- Manage your account and preferences -

-
- -
- <.icon name="hero-user-circle" class="w-12 h-12 mx-auto text-base-content/30 mb-3" /> -

Account settings coming soon.

-

- This section will allow you to manage your profile and account preferences. -

-
-
- """ - end - - defp auth_integrations_tab(assigns) do - ~H""" -
-
-

Auth & Integrations

-

- Manage hosted provider login broker trust separately from deployment-local Git automation credentials. -

-
- -
-
-
-

- Operator Settings -

-

Provider Login

-

- Configure broker trust and allowlist policy separately from deployment-local Git automation credentials. -

-
-
- Local email sign-in stays available even if every provider card below is disabled. -
-
- -
-
-
-
-

{card.display_name}

-

{card.provider_host}

-
- - {card.status.label} - -
- -

{card.status.detail}

- -

- Missing: {Enum.join(card.status.missing_fields, ", ")} -

- -

{card.entrypoint_note}

- - <.form - for={%{}} - as={:provider_login} - id={"settings-auth-provider-login-form-#{card.provider}"} - phx-submit="save_provider_login" - class="mt-5 space-y-3" - > - - -
- <.input - type="checkbox" - name="provider_login[enabled]" - checked={card.enabled} - label="Enable provider host" - /> - <.input - type="checkbox" - name="provider_login[login_enabled]" - checked={card.login_enabled} - label="Allow browser sign-in" - /> -
- - <.input - type="select" - name="provider_login[allowlist_mode]" - label="Allowlist Mode" - value={card.allowlist_mode} - options={@operator_auth_allowlist_options} - /> - - <.input - type="textarea" - name="provider_login[allowlist_values]" - label="Allowlist Values" - value={card.allowlist_values_text} - rows="4" - placeholder="One value per line or comma separated" - /> - -
-

- Broker Trust -

-
- <.input - type="url" - name="provider_login[broker_base_url]" - label="Broker Base URL" - value={card.broker_base_url} - placeholder="https://broker.example.com" - /> - <.input - type="text" - name="provider_login[broker_issuer]" - label="Broker Issuer" - value={card.broker_issuer} - placeholder="https://broker.example.com" - /> - <.input - type="text" - name="provider_login[broker_audience]" - label="Broker Audience" - value={card.broker_audience} - placeholder="jido-code" - /> -
-
- - - -
-
-
- -
-
-
-

Git Provider Integrations

-

- Deployment-local Git automation credentials stay separate from provider-login broker trust. GitHub App is preferred, PAT is fallback, and GitLab or Bitbucket remain placeholders for now. -

-
-
- -
-
-
-
-

GitHub Service Integration

-

- Deployment-local automation credentials and repository access validation -

-
- - {OperatorAuthSettings.integration_status_label(@github_service_report.status)} - -
- -
-

- {OperatorAuthSettings.github_service_summary(@github_service_report)} -

- -

- Last checked: {OperatorAuthSettings.format_datetime(@github_service_report.checked_at)} -

- -
-
-
-
-

{path_result.name}

-

{path_result.detail}

-
- - {OperatorAuthSettings.integration_status_label(path_result.status)} - -
- -

- Repository access: {OperatorAuthSettings.repository_access_label(path_result.repository_access)} -

- -

- Missing repositories: {Enum.join(path_result.missing_repositories, ", ")} -

- -

- Remediation: {path_result.remediation} -

-
-
-
-
- -
-
-

Deployment-Local Secrets

-

- These secret refs back GitHub automation. They are not stored in the provider-login broker config. -

- -
-

- {OperatorAuthSettings.service_credential_label(credential)}: - {secret_ref} -

-
-
- -
-

Future Providers

-
-
-

GitLab

-

- Login config is modeled, but GitLab service automation is still a named placeholder adapter. -

-
-
-

Bitbucket

-

- Login config is modeled, but Bitbucket service automation is still a named placeholder adapter. -

-
-
-
-
-
-
-
- """ - end - - defp security_tab(assigns) do - ~H""" -
-
-

Security Controls

-

- Review token/key expiry, revoke compromised credentials, and capture revocation audit timestamps. -

-
- -
-

- Typed error: {@security_status_error.error_type} -

-

- {@security_status_error.message} -

-

- {@security_status_error.recovery_instruction} -

-
- -
-

- Typed error: {@security_revocation_error.error_type} -

-

- {@security_revocation_error.message} -

-

- {@security_revocation_error.recovery_instruction} -

-
- -
-
-
-

Operational Secret References

-

- Persist operational values as encrypted SecretRef ciphertext while keeping metadata queryable. -

-
- -
-

- Typed error: {@security_secret_error.error_type} -

-

- {@security_secret_error.message} -

-

- {@security_secret_error.recovery_instruction} -

-
- - <.form - id="settings-security-secret-form" - for={@security_secret_form} - phx-submit="save_security_secret_ref" - class="grid gap-4 md:grid-cols-2" - > - <.input - id="settings-security-secret-scope" - field={@security_secret_form[:scope]} - type="select" - options={@secret_scope_options} - label="Scope" - /> - <.input - id="settings-security-secret-name" - field={@security_secret_form[:name]} - type="text" - label="Name" - placeholder="github/webhook_secret" - /> - <.input - id="settings-security-secret-value" - field={@security_secret_form[:value]} - type="password" - label="Secret Value" - placeholder="Never shown after save" - /> - -
- -
- - -
-
- No SecretRef metadata stored yet. -
- -
-
-
-
-
Scope
-
- {secret.scope} -
-
-
-
Name
-
- {secret.name} -
-
-
-
Source
-
- {secret.source} -
-
-
-
Key Version
-
- {secret.key_version} -
-
-
-
Last Rotated At
-
- {format_security_datetime(secret.last_rotated_at)} -
-
-
-
Expires At
-
- {format_optional_security_datetime(secret.expires_at)} -
-
-
- -
-
-
-
-
- -
-
-
-

Provider Credential Rotation

-

- Rotate provider credentials with atomic reference updates and rollback protection. -

-
- -
-

- Typed error: {@security_provider_rotation_error.error_type} -

-

- {@security_provider_rotation_error.message} -

-

- {@security_provider_rotation_error.recovery_instruction} -

-
- - <.form - id="settings-security-provider-rotation-form" - for={@security_provider_rotation_form} - phx-submit="rotate_security_provider_credential" - class="grid gap-4 md:grid-cols-2" - > - <.input - id="settings-security-provider-rotation-provider" - field={@security_provider_rotation_form[:provider]} - type="select" - options={@provider_rotation_options} - label="Provider" - /> - <.input - id="settings-security-provider-rotation-value" - field={@security_provider_rotation_form[:value]} - type="password" - label="New credential value" - placeholder="Never shown after rotation" - /> -
- -
- - -
-
-
-
Provider
-
- {@security_provider_rotation_report.provider} -
-
-
-
Credential Name
-
- {@security_provider_rotation_report.name} -
-
-
-
Before Version
-
- {@security_provider_rotation_report.before.key_version} -
-
-
-
Before Verification
-
- {provider_rotation_verification_label(@security_provider_rotation_report.before.verification.status)} -
-
-
-
After Version
-
- {@security_provider_rotation_report.after.key_version} -
-
-
-
After Verification
-
- {provider_rotation_verification_label(@security_provider_rotation_report.after.verification.status)} -
-
-
-
Rollback
-
- {provider_rotation_rollback_label(@security_provider_rotation_report.rollback_performed)} -
-
-
-
Continuity Alarm
-
- {provider_rotation_alarm_label(@security_provider_rotation_report.continuity_alarm)} -
-
-
-
-
-
- -
-
-

Secret Lifecycle Audit

- -
-

- Typed error: {@security_secret_audit_error.error_type} -

-

- {@security_secret_audit_error.message} -

-

- {@security_secret_audit_error.recovery_instruction} -

-
- -
    -
  • - {security_secret_lifecycle_audit_message(audit)} -
  • -
- -

- No secret lifecycle events recorded yet. -

-
-
- -
-

Session Tokens

- -
- No owner session tokens found. -
- -
-
-
-
-
Status
-
- {security_status_label(token.status)} -
-
-
-
Purpose
-
- {token.purpose} -
-
-
-
Expires At
-
- {format_security_datetime(token.expires_at)} -
-
-
-
Revoked At
-
- {format_security_datetime(token.revoked_at)} -
-
-
- - -
-
-
- -
-

API Keys

- -
- No owner API keys found. -
- -
-
-
-
-
Status
-
- {security_status_label(api_key.status)} -
-
-
-
ID
-
- {api_key.id} -
-
-
-
Expires At
-
- {format_security_datetime(api_key.expires_at)} -
-
-
-
Revoked At
-
- {format_security_datetime(api_key.revoked_at)} -
-
-
- - -
-
-
- -
-

Revocation Audit

-
    -
  • - {security_audit_message(audit)} -
  • -
-

- No revocation events recorded in this browser session. -

-
-
- """ - end - - @impl true - def handle_event("toggle_repo", %{"id" => id}, socket) do - actor = settings_actor(socket) - repo = Repo.get_by_id!(id, actor: actor) - - result = - if repo.enabled do - Repo.disable(repo, actor: actor) - else - Repo.enable(repo, actor: actor) - end - - case result do - {:ok, updated_repo} -> - {:noreply, - socket - |> stream_insert(:repos, updated_repo) - |> load_repos()} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to update repository")} - end - end - - def handle_event("delete_repo", %{"id" => id}, socket) do - actor = settings_actor(socket) - repo = Repo.get_by_id!(id, actor: actor) - - case Ash.destroy(repo, actor: actor) do - :ok -> - {:noreply, - socket - |> stream_delete(:repos, repo) - |> load_repos()} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete repository")} - end - end - - def handle_event("open_add_modal", _params, socket) do - form = - Repo - |> AshPhoenix.Form.for_create(:create, actor: settings_actor(socket)) - |> to_form() - - {:noreply, assign(socket, show_add_modal: true, form: form, repo_save_error: nil)} - end - - def handle_event("close_add_modal", _params, socket) do - {:noreply, assign(socket, show_add_modal: false, form: nil, repo_save_error: nil)} - end - - def handle_event("validate", %{"form" => params}, socket) do - form = - socket.assigns.form.source - |> AshPhoenix.Form.validate(params) - |> to_form() - - {:noreply, assign(socket, form: form, repo_save_error: nil)} - end - - def handle_event("save_repo", %{"form" => params}, socket) do - validated_form = AshPhoenix.Form.validate(socket.assigns.form.source, params) - - cond do - validated_form.valid? != true -> - {:noreply, assign(socket, form: to_form(validated_form), repo_save_error: nil)} - - true -> - with {:ok, repo_identity} <- normalize_repo_identity(params), - {:ok, import_report} <- import_settings_repo(repo_identity.full_name), - {:ok, _repo} <- ensure_settings_repo_anchor(repo_identity, settings_actor(socket)) do - socket = - socket - |> load_repos() - |> assign(show_add_modal: false, form: nil, repo_save_error: nil) - |> put_flash(:info, settings_repo_import_success_message(import_report)) - - {:noreply, socket} - else - {:error, {:validation, message}} -> - {:noreply, - assign( - socket, - form: to_form(validated_form), - repo_save_error: message - )} - - {:error, {:import_failed, message}} -> - {:noreply, - assign( - socket, - form: to_form(validated_form), - repo_save_error: message - )} - - {:error, {:anchor_failed, message}} -> - {:noreply, - assign( - socket, - form: to_form(validated_form), - repo_save_error: message - )} - end - end - end - - def handle_event("save_provider_login", %{"provider_login" => params}, socket) do - with {:ok, provider} <- OperatorAuthSettings.save_provider_login(params) do - {:noreply, - socket - |> put_flash(:info, "Saved #{OperatorAuthSettings.provider_display_name(provider)} provider login settings.") - |> load_operator_auth_settings()} - else - {:error, message} when is_binary(message) -> - {:noreply, put_flash(socket, :error, message)} - - {:error, error} -> - {:noreply, put_flash(socket, :error, inspect(error))} - end - end - - def handle_event("refresh_github_service_checks", _params, socket) do - {:noreply, load_operator_auth_settings(socket)} - end - - def handle_event("save_security_secret_ref", %{"security_secret" => params}, socket) do - case SecretRefs.persist_operational_secret(Map.put(params, "actor", current_actor(socket))) do - {:ok, _secret_metadata} -> - socket = - socket - |> assign(:security_secret_error, nil) - |> assign(:security_secret_form, empty_security_secret_form()) - |> load_security_secret_metadata() - |> load_security_secret_lifecycle_audits() - |> put_flash(:info, "SecretRef saved.") - - {:noreply, socket} - - {:error, typed_error} -> - socket = - socket - |> assign(:security_secret_error, typed_error) - |> assign( - :security_secret_form, - empty_security_secret_form(%{ - "scope" => Map.get(params, "scope", "integration"), - "name" => Map.get(params, "name", ""), - "value" => "" - }) - ) - - {:noreply, socket} - end - end - - def handle_event("rotate_security_provider_credential", %{"security_provider_rotation" => params}, socket) do - case SecretRefs.rotate_provider_credential(Map.put(params, "actor", current_actor(socket))) do - {:ok, rotation_report} -> - socket = - socket - |> assign(:security_provider_rotation_error, nil) - |> assign(:security_provider_rotation_report, rotation_report) - |> assign( - :security_provider_rotation_form, - empty_security_provider_rotation_form(%{ - "provider" => Map.get(params, "provider", "anthropic"), - "value" => "" - }) - ) - |> load_security_secret_metadata() - |> load_security_secret_lifecycle_audits() - |> put_flash(:info, "Provider credential rotated.") - - {:noreply, socket} - - {:error, typed_error} -> - socket = - socket - |> assign(:security_provider_rotation_error, typed_error) - |> assign(:security_provider_rotation_report, Map.get(typed_error, :rotation_report)) - |> assign( - :security_provider_rotation_form, - empty_security_provider_rotation_form(%{ - "provider" => Map.get(params, "provider", "anthropic"), - "value" => "" - }) - ) - |> load_security_secret_metadata() - |> load_security_secret_lifecycle_audits() - - {:noreply, socket} - end - end - - def handle_event("revoke_security_secret_ref", %{"id" => secret_ref_id}, socket) do - params = %{"id" => secret_ref_id, "actor" => current_actor(socket)} - - case SecretRefs.revoke_operational_secret(params) do - {:ok, _revoked_secret} -> - socket = - socket - |> assign(:security_secret_error, nil) - |> load_security_secret_metadata() - |> load_security_secret_lifecycle_audits() - |> put_flash(:info, "SecretRef revoked.") - - {:noreply, socket} - - {:error, typed_error} -> - {:noreply, assign(socket, :security_secret_error, typed_error)} - end - end - - def handle_event("revoke_security_token", %{"jti" => jti}, socket) do - owner_id = current_owner_id(socket) - - case SecurityTokens.revoke_owner_token(owner_id, jti) do - {:ok, audit_entry} -> - socket = - socket - |> assign(:security_revocation_error, nil) - |> prepend_security_audit_event(audit_entry) - |> load_security_status() - |> put_flash(:info, "Token revoked.") - - {:noreply, socket} - - {:error, typed_error} -> - {:noreply, assign(socket, :security_revocation_error, typed_error)} - end - end - - def handle_event("revoke_security_api_key", %{"id" => api_key_id}, socket) do - owner_id = current_owner_id(socket) - - case SecurityTokens.revoke_owner_api_key(owner_id, api_key_id) do - {:ok, audit_entry} -> - socket = - socket - |> assign(:security_revocation_error, nil) - |> prepend_security_audit_event(audit_entry) - |> load_security_status() - |> put_flash(:info, "API key revoked.") - - {:noreply, socket} - - {:error, typed_error} -> - {:noreply, assign(socket, :security_revocation_error, typed_error)} - end - end - - defp maybe_load_settings_tab(socket, "auth") do - load_operator_auth_settings(socket) - end - - defp maybe_load_settings_tab(socket, "security") do - socket - |> load_security_status() - |> load_security_secret_metadata() - |> load_security_secret_lifecycle_audits() - end - - defp maybe_load_settings_tab(socket, _tab), do: socket - - defp load_operator_auth_settings(socket) do - assign(socket, OperatorAuthSettings.state(socket.assigns[:current_user])) - end - - defp load_repos(socket) do - repos = Repo.read!(actor: settings_actor(socket)) - - socket - |> assign(:repo_count, length(repos)) - |> assign(:enabled_repo_count, Enum.count(repos, &Map.get(&1, :enabled, false))) - |> stream(:repos, repos, reset: true) - end - - defp import_settings_repo(full_name) when is_binary(full_name) do - case ProjectImport.run(nil, full_name, %{}) do - %{status: :ready} = report -> - {:ok, report} - - report -> - {:error, {:import_failed, settings_repo_import_error_message(report)}} - end - end - - defp import_settings_repo(_full_name) do - {:error, {:validation, "Enter a valid GitHub repository owner and name."}} - end - - defp ensure_settings_repo_anchor(%{full_name: full_name} = repo_identity, actor) do - case Repo.get_by_full_name(full_name, actor: actor) do - {:ok, repo} -> - {:ok, repo} - - _other -> - create_settings_repo_anchor(repo_identity, actor) - end - end - - defp create_settings_repo_anchor(%{owner: owner, name: name}, actor) do - case Repo.create(%{owner: owner, name: name}, actor: actor) do - {:ok, repo} -> - {:ok, repo} - - {:error, reason} -> - case Repo.get_by_full_name("#{owner}/#{name}", actor: actor) do - {:ok, repo} -> - {:ok, repo} - - _other -> - {:error, - {:anchor_failed, - "Repository import succeeded, but the settings GitHub anchor could not be saved (#{inspect(reason)})."}} - end - end - end - - defp normalize_repo_identity(params) when is_map(params) do - owner = - params - |> Map.get("owner") - |> normalize_optional_string() - - name = - params - |> Map.get("name") - |> normalize_optional_string() - - case {owner, name} do - {owner, name} when is_binary(owner) and is_binary(name) -> - {:ok, %{owner: owner, name: name, full_name: "#{owner}/#{name}"}} - - _other -> - {:error, {:validation, "Enter a valid GitHub repository owner and name."}} - end - end - - defp normalize_repo_identity(_params) do - {:error, {:validation, "Enter a valid GitHub repository owner and name."}} - end - - defp normalize_optional_string(nil), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), - do: value |> Integer.to_string() |> normalize_optional_string() - - defp normalize_optional_string(_value), do: nil - - defp settings_repo_import_success_message(%{detail: detail}) when is_binary(detail), do: detail - - defp settings_repo_import_success_message(_report) do - "Repository imported into the managed-repository control plane." - end - - defp settings_repo_import_error_message(%{detail: detail, remediation: remediation}) do - [detail, remediation] - |> Enum.filter(&is_binary/1) - |> Enum.join(" ") - |> case do - "" -> "Repository import failed." - message -> message - end - end - - defp settings_repo_import_error_message(%{detail: detail}) when is_binary(detail), do: detail - - defp settings_repo_import_error_message(_report), do: "Repository import failed." - - defp load_security_status(socket) do - owner_id = current_owner_id(socket) - - case SecurityTokens.list_owner_credentials(owner_id) do - {:ok, %{tokens: tokens, api_keys: api_keys}} -> - socket - |> assign(:security_tokens, tokens) - |> assign(:security_api_keys, api_keys) - |> assign(:security_status_error, nil) - - {:error, typed_error} -> - socket - |> assign(:security_tokens, []) - |> assign(:security_api_keys, []) - |> assign(:security_status_error, typed_error) - end - end - - defp prepend_security_audit_event(socket, audit_entry) do - event = - audit_entry - |> Map.put(:event_id, System.unique_integer([:positive])) - - assign(socket, :security_audit_events, [event | socket.assigns.security_audit_events]) - end - - defp load_security_secret_metadata(socket) do - case SecretRefs.list_secret_metadata() do - {:ok, secret_refs} -> - socket - |> assign(:security_secret_refs, secret_refs) - |> assign(:security_secret_error, nil) - - {:error, typed_error} -> - socket - |> assign(:security_secret_refs, []) - |> assign(:security_secret_error, typed_error) - end - end - - defp load_security_secret_lifecycle_audits(socket) do - case SecretRefs.list_secret_lifecycle_audits() do - {:ok, audits} -> - socket - |> assign(:security_secret_lifecycle_audits, audits) - |> assign(:security_secret_audit_error, nil) - - {:error, typed_error} -> - socket - |> assign(:security_secret_lifecycle_audits, []) - |> assign(:security_secret_audit_error, typed_error) - end - end - - defp current_owner_id(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{id: id} -> id - _ -> nil - end - end - - defp current_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{id: id} = user -> - %{ - "id" => id, - "email" => Map.get(user, :email) - } - - _other -> - %{} - end - end - - defp security_status_label(:active), do: "Active" - defp security_status_label(:expired), do: "Expired" - defp security_status_label(:revoked), do: "Revoked" - - defp format_security_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) - defp format_security_datetime(nil), do: "Not revoked" - defp format_security_datetime(_value), do: "Unavailable" - - defp format_optional_security_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) - defp format_optional_security_datetime(nil), do: "Never" - defp format_optional_security_datetime(_value), do: "Unavailable" - - defp empty_security_secret_form(params \\ %{}) do - defaults = %{"scope" => "integration", "name" => "", "value" => ""} - params = Map.merge(defaults, params) - to_form(params, as: :security_secret) - end - - defp empty_security_provider_rotation_form(params \\ %{}) do - defaults = %{"provider" => "anthropic", "value" => ""} - params = Map.merge(defaults, params) - to_form(params, as: :security_provider_rotation) - end - - defp security_audit_message(audit) do - source_label = - case Map.get(audit, :source) do - :session_token -> "Session token" - :api_key -> "API key" - end - - "#{source_label} #{Map.get(audit, :id)} revoked at #{format_security_datetime(Map.get(audit, :revoked_at))}." - end - - defp security_secret_lifecycle_audit_message(audit) do - action = audit |> Map.get(:action_type) |> to_string() |> String.upcase() - outcome = audit |> Map.get(:outcome_status) |> to_string() - target = "#{Map.get(audit, :scope)}/#{Map.get(audit, :name)}" - - actor = - case Map.get(audit, :actor_email) do - email when is_binary(email) and email != "" -> email - _ -> Map.get(audit, :actor_id) - end - - "#{action} #{target} outcome=#{outcome} actor=#{actor} at #{format_security_datetime(Map.get(audit, :occurred_at))}." - end - - defp repo_settings_summary(settings) when is_map(settings) and map_size(settings) > 0 do - rendered_settings = inspect(Map.keys(settings)) - redaction = UiRedaction.sanitize_text(rendered_settings) - - %{ - text: redaction.text, - security_alert?: redaction.security_alert?, - alert_message: UiRedaction.security_alert_message(redaction.reason) - } - end - - defp repo_settings_summary(_settings) do - %{ - text: "No custom settings", - security_alert?: false, - alert_message: nil - } - end - - defp provider_rotation_verification_label(:passed), do: "Passed" - defp provider_rotation_verification_label(:failed), do: "Failed" - defp provider_rotation_verification_label(_status), do: "Unavailable" - - defp provider_rotation_rollback_label(true), do: "Yes" - defp provider_rotation_rollback_label(false), do: "No" - - defp provider_rotation_alarm_label(true), do: "Raised" - defp provider_rotation_alarm_label(false), do: "None" - - defp settings_breadcrumbs(assigns) do - [ - OperatorShell.breadcrumb(%{ - id: "settings-breadcrumb-settings", - label: "Settings", - patch: ~p"/settings" - }), - OperatorShell.breadcrumb(%{ - id: "settings-breadcrumb-current", - label: settings_tab_nav_label(Map.get(assigns, :active_tab)), - current?: true - }) - ] - end - - defp settings_nav_items(assigns) do - active_tab = Map.get(assigns, :active_tab) || "github" - - for tab <- ~w(github agents account auth security) do - OperatorShell.child_subject(%{ - id: tab, - label: settings_tab_nav_label(tab), - summary: settings_tab_summary(tab, assigns), - pane_id: "settings-pane-#{tab}", - selected?: active_tab == tab, - patch: settings_tab_path(tab) - }) - end - end - - defp settings_selected_pane(assigns) do - active_tab = Map.get(assigns, :active_tab) || "github" - - OperatorShell.pane(%{ - id: "settings-pane-#{active_tab}", - title: settings_pane_title(active_tab), - summary: settings_active_tab_summary(assigns) - }) - end - - defp settings_overview_cards(assigns) do - [ - %{ - id: "github", - label: "GitHub repos", - value: Integer.to_string(Map.get(assigns, :repo_count, 0)), - detail: "#{Map.get(assigns, :enabled_repo_count, 0)} enabled for operator workflows", - active: Map.get(assigns, :active_tab) == "github" - }, - %{ - id: "agents", - label: "Agent config", - value: "Soon", - detail: "Agent behavior remains server-owned while the settings shell evolves.", - active: Map.get(assigns, :active_tab) == "agents" - }, - %{ - id: "account", - label: "Account", - value: "LiveView", - detail: "Identity and session preferences still live in the routed host shell.", - active: Map.get(assigns, :active_tab) == "account" - }, - %{ - id: "auth", - label: "Auth & integrations", - value: auth_overview_value(assigns), - detail: auth_overview_detail(assigns), - active: Map.get(assigns, :active_tab) == "auth" - }, - %{ - id: "security", - label: "Security", - value: security_overview_value(assigns), - detail: security_overview_detail(assigns), - active: Map.get(assigns, :active_tab) == "security" - } - ] - end - - defp settings_active_tab_summary(assigns) do - case Map.get(assigns, :active_tab) do - "github" -> - "#{Map.get(assigns, :repo_count, 0)} GitHub repo(s) connected, with #{Map.get(assigns, :enabled_repo_count, 0)} currently enabled for workflow execution." - - "agents" -> - "Agent configuration remains an incremental adoption surface; richer client composition does not change server-owned policy or execution defaults." - - "account" -> - "Account preferences stay routed through LiveView while this overview surfaces the active operator context." - - "auth" -> - "Provider login broker trust and GitHub automation readiness stay grouped on this settings-owned authenticated surface." - - "security" -> - "Security controls currently expose #{security_credential_count(assigns)} governed credential or secret record(s) in the active operator context." - - _other -> - "Settings overview keeps the route in LiveView while grouping the active operator snapshot for faster scanning." - end - end - - defp security_overview_value(assigns) do - if Map.get(assigns, :active_tab) == "security" do - Integer.to_string(security_credential_count(assigns)) - else - "Load tab" - end - end - - defp auth_overview_value(assigns) do - if Map.get(assigns, :active_tab) == "auth" do - assigns - |> Map.get(:github_service_report, %{}) - |> Map.get(:status, :not_configured) - |> OperatorAuthSettings.integration_status_label() - else - "Open tab" - end - end - - defp auth_overview_detail(assigns) do - if Map.get(assigns, :active_tab) == "auth" do - "#{length(Map.get(assigns, :provider_login_cards, []))} provider host(s) modeled, #{length(Map.get(assigns, :github_service_secret_refs, []))} deployment-local Git secret ref(s) listed." - else - "Open Auth & Integrations to manage provider login broker trust and GitHub automation readiness." - end - end - - defp security_overview_detail(assigns) do - if Map.get(assigns, :active_tab) == "security" do - "#{length(Map.get(assigns, :security_secret_refs, []))} secret ref(s), #{length(Map.get(assigns, :security_tokens, []))} session token(s), #{length(Map.get(assigns, :security_api_keys, []))} API key(s)." - else - "Open Security to load governed credentials, revocations, and secret metadata." - end - end - - defp security_credential_count(assigns) do - length(Map.get(assigns, :security_tokens, [])) + - length(Map.get(assigns, :security_api_keys, [])) + - length(Map.get(assigns, :security_secret_refs, [])) - end - - defp settings_tab_path(tab) when tab in ~w(github agents account auth security), do: ~p"/settings/#{tab}" - defp settings_tab_path(_tab), do: ~p"/settings" - - defp settings_tab_nav_label("github"), do: "GitHub" - defp settings_tab_nav_label("agents"), do: "Agents" - defp settings_tab_nav_label("account"), do: "Account" - defp settings_tab_nav_label("auth"), do: "Auth & Integrations" - defp settings_tab_nav_label("security"), do: "Security" - defp settings_tab_nav_label(_tab), do: "Settings" - - defp settings_pane_title("github"), do: "GitHub repositories" - defp settings_pane_title("agents"), do: "Agent settings" - defp settings_pane_title("account"), do: "Account settings" - defp settings_pane_title("auth"), do: "Auth & integrations" - defp settings_pane_title("security"), do: "Security settings" - defp settings_pane_title(_tab), do: "Settings" - - defp settings_tab_summary("github", assigns) do - "#{Map.get(assigns, :repo_count, 0)} connected repo(s), #{Map.get(assigns, :enabled_repo_count, 0)} enabled for operator workflows." - end - - defp settings_tab_summary("agents", _assigns) do - "Agent behavior remains server-owned while this route reuses the shared signed-in shell." - end - - defp settings_tab_summary("account", _assigns) do - "Identity and session preferences stay routed through LiveView on this operator-owned surface." - end - - defp settings_tab_summary("auth", assigns), do: auth_overview_detail(assigns) - defp settings_tab_summary("security", assigns), do: security_overview_detail(assigns) - defp settings_tab_summary(_tab, _assigns), do: nil - - defp settings_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - Actor.operator_actor(%{ - "id" => Map.get(user, :id) || Map.get(user, "id"), - "email" => Map.get(user, :email) || Map.get(user, "email") - }) - - _other -> - Actor.operator_actor(%{ - "id" => "system:settings-live", - "email" => "settings-live@system.local" - }) - end - end + use JidoCodeWeb.ResetLive, title: "Settings" end diff --git a/lib/jido_code_web/live/setup_live.ex b/lib/jido_code_web/live/setup_live.ex index 3ad05e6a..74f927de 100644 --- a/lib/jido_code_web/live/setup_live.ex +++ b/lib/jido_code_web/live/setup_live.ex @@ -1,2538 +1,3 @@ defmodule JidoCodeWeb.SetupLive do - # covers: baseline.surface.public_entry_routes - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.setup_entry_surface_uses_bounded_live_vue_regions - # covers: setup.onboarding.post_bootstrap_start_surface - # covers: setup.onboarding.deployment_mode_auto_detected - # covers: setup.onboarding.runtime_environment_selection_distinct_from_install_flavor - # covers: setup.onboarding.runtime_environment_selection_persisted_metadata - # covers: setup.onboarding.explicit_completion_path_to_dashboard - # covers: setup.onboarding.deferred_integrations - # covers: setup.onboarding.github_repository_selection_persisted_metadata - # covers: setup.onboarding.github_repository_selection_prefers_live_vue_widget_with_liveview_fallback - # covers: setup.onboarding.github_repository_selection_supports_account_filter_and_matching_search - # covers: setup.onboarding.github_pat_capture_persisted_secret_ref - # covers: setup.onboarding.github_pat_capture_requires_encryption_ready_secret_storage - # covers: setup.onboarding.hybrid_follow_up_regions_keep_sensitive_controls_liveview_owned - # covers: setup.onboarding.start_path_preference_persisted - use JidoCodeWeb, :live_view - - alias JidoCode.Control.Actor - alias JidoCode.GitHub.ServiceCredentials - alias JidoCode.Setup.EnvironmentDefaults - alias JidoCode.Setup.DeploymentMode - alias JidoCode.Setup.GitHubCredentialChecks - alias JidoCode.Setup.GitHubRepositoryListing - alias JidoCode.Setup.ProjectImport - alias JidoCode.Setup.SystemConfig - alias JidoCode.Security.{Encryption, SecretRefs} - - @github_setup_step 7 - @start_paths [:local_repo, :github, :later] - @runtime_environment_options [{"Cloud", "cloud"}, {"Local", "local"}] - - @type start_path :: :local_repo | :github | :later - - @impl true - def mount(params, _session, socket) do - diagnostic = normalize_optional_string(params["diagnostic"]) - - case SystemConfig.load() do - {:ok, %SystemConfig{} = config} -> - route_setup(socket, config, diagnostic, false) - - {:error, %{diagnostic: load_diagnostic, onboarding_step: onboarding_step}} -> - fallback_config = %SystemConfig{ - onboarding_completed: false, - onboarding_step: max(onboarding_step, 3), - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - } - - route_setup(socket, fallback_config, diagnostic || load_diagnostic, true) - end - end - - @impl true - def handle_event("choose_start_path", %{"choice" => choice}, socket) do - normalized_choice = normalize_start_path(choice) - allowed_choices = Enum.map(socket.assigns.start_options, & &1.id) - - cond do - socket.assigns.buttons_disabled? -> - {:noreply, assign(socket, :save_error, "Resolve the setup state issue before saving a start path.")} - - normalized_choice not in allowed_choices -> - {:noreply, assign(socket, :save_error, "Choose a valid way to start.")} - - true -> - case persist_start_path( - socket.assigns.deployment_mode, - normalized_choice, - socket.assigns.owner_email - ) do - {:ok, %SystemConfig{} = config} -> - {:noreply, - socket - |> assign_start_surface(config, socket.assigns.diagnostic, false) - |> put_flash(:info, "#{start_path_title(normalized_choice)} saved as your default start path.")} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - end - end - - @impl true - def handle_event("change_runtime_environment", %{"runtime_environment" => runtime_params}, socket) do - {:noreply, - socket - |> assign_runtime_environment_form(runtime_params) - |> assign(:runtime_save_error, nil)} - end - - @impl true - def handle_event("save_runtime_environment", %{"runtime_environment" => runtime_params}, socket) do - runtime_report = EnvironmentDefaults.run(runtime_params) - - cond do - socket.assigns.buttons_disabled? -> - {:noreply, - assign( - socket, - :runtime_save_error, - "Resolve the setup state issue before saving runtime defaults." - )} - - EnvironmentDefaults.blocked?(runtime_report) -> - {:noreply, - socket - |> assign_runtime_environment_form(runtime_params) - |> assign(:runtime_save_error, runtime_environment_error_message(runtime_report))} - - true -> - case persist_runtime_environment(socket.assigns.deployment_mode, runtime_report) do - {:ok, %SystemConfig{} = config} -> - {:noreply, - socket - |> assign_start_surface(config, socket.assigns.diagnostic, false) - |> put_flash( - :info, - "#{runtime_environment_title(runtime_report.mode)} runtime defaults saved." - )} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, - socket - |> assign_runtime_environment_form(runtime_params) - |> assign(:runtime_save_error, diagnostic)} - end - end - end - - @impl true - def handle_event("complete_setup", _params, socket) do - cond do - socket.assigns.buttons_disabled? -> - {:noreply, - assign( - socket, - :save_error, - "Resolve the setup state issue before continuing into the app." - )} - - true -> - case complete_setup(socket.assigns.deployment_mode, socket.assigns.selected_start_path) do - {:ok, _config} -> - {:noreply, - socket - |> put_flash(:info, "Setup complete. Optional follow-up work remains available from the dashboard.") - |> push_navigate(to: ~p"/dashboard?onboarding=completed")} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - end - end - - @impl true - def handle_event("select_github_repository", params, socket) do - selected_repositories = - resolve_selected_repositories(params, socket.assigns.github_selected_repositories) - - cond do - socket.assigns.buttons_disabled? -> - {:noreply, - assign( - socket, - :save_error, - "Resolve the setup state issue before saving a GitHub repository selection." - )} - - not valid_github_repository_selection?( - selected_repositories, - socket.assigns.github_repository_full_names - ) -> - {:noreply, assign(socket, :save_error, "Choose validated GitHub repositories.")} - - true -> - case persist_github_repository_selection(selected_repositories) do - {:ok, %SystemConfig{} = config} -> - {:noreply, assign_start_surface(socket, config, socket.assigns.diagnostic, false)} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - end - end - - @impl true - def handle_event("filter_github_repositories", %{"repository_filters" => filter_params}, socket) do - {:noreply, assign_github_repository_filters(socket, filter_params)} - end - - @impl true - def handle_event("refresh_github_repository_listing", _params, socket) do - cond do - socket.assigns.buttons_disabled? -> - {:noreply, - assign( - socket, - :save_error, - "Resolve the setup state issue before refreshing GitHub repositories." - )} - - true -> - case refresh_github_repository_listing(socket.assigns.owner_email) do - {:ok, %SystemConfig{} = config} -> - {:noreply, - socket - |> assign_start_surface(config, socket.assigns.diagnostic, false) - |> put_flash(:info, "GitHub repository access refreshed.")} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - end - end - - @impl true - def handle_event("save_github_pat", %{"github_pat" => params}, socket) do - github_pat = - params - |> map_get(:value, "value") - |> normalize_optional_string() - - cond do - socket.assigns.buttons_disabled? -> - {:noreply, - assign( - socket, - :github_pat_save_error, - "Resolve the setup state issue before saving GitHub credentials." - )} - - not socket.assigns.github_pat_capture_state.encryption_ready? -> - {:noreply, - socket - |> assign_github_pat_form() - |> assign( - :github_pat_save_error, - socket.assigns.github_pat_capture_state.encryption_preflight_detail - )} - - is_nil(github_pat) -> - {:noreply, - socket - |> assign_github_pat_form() - |> assign(:github_pat_save_error, "Enter a GitHub PAT before saving it.")} - - true -> - case SecretRefs.persist_operational_secret(%{ - scope: :integration, - name: ServiceCredentials.service_secret_ref_name(:pat), - value: github_pat, - source: :onboarding, - actor: current_actor(socket) - }) do - {:ok, _secret_metadata} -> - case refresh_github_repository_listing(socket.assigns.owner_email) do - {:ok, %SystemConfig{} = config} -> - {:noreply, - socket - |> assign_start_surface(config, socket.assigns.diagnostic, false) - |> put_flash( - :info, - "GitHub PAT saved to encrypted deployment-local secret storage. Repository access refreshed." - )} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, - socket - |> assign_github_pat_form() - |> assign( - :github_pat_save_error, - "GitHub PAT was saved, but repository access could not be refreshed. #{diagnostic}" - )} - end - - {:error, typed_error} -> - {:noreply, - socket - |> assign_github_pat_form() - |> assign(:github_pat_save_error, secret_persistence_error_message(typed_error))} - end - end - end - - @impl true - def handle_event("import_selected_github_repository", params, socket) do - selected_repositories = - resolve_selected_repositories(params, socket.assigns.github_selected_repositories) - - cond do - socket.assigns.buttons_disabled? -> - {:noreply, - assign( - socket, - :save_error, - "Resolve the setup state issue before importing a GitHub repository." - )} - - selected_repositories == [] -> - {:noreply, assign(socket, :save_error, "Select at least one GitHub repository before importing it.")} - - not valid_github_repository_selection?( - selected_repositories, - socket.assigns.github_repository_full_names - ) -> - {:noreply, assign(socket, :save_error, "Choose validated GitHub repositories.")} - - true -> - case persist_github_project_import(selected_repositories, socket.assigns.owner_email) do - {:ok, %SystemConfig{} = config} -> - project_import_report = github_project_import_report(config.onboarding_state) - - flash_kind = - if github_project_import_ready?(project_import_report), do: :info, else: :error - - {:noreply, - socket - |> assign_start_surface(config, socket.assigns.diagnostic, false) - |> put_flash( - flash_kind, - github_project_import_flash(project_import_report, selected_repositories) - )} - - {:error, %{diagnostic: diagnostic}} -> - {:noreply, assign(socket, :save_error, diagnostic)} - end - end - end - - @impl true - def render(assigns) do - ~H""" - -
-
-
-
-

- Setup -

-

- Choose how to start -

-

- {setup_description(@deployment_mode)} -

-
- - <.vue_surface - id="setup-runtime-defaults-widget" - component="SetupRuntimeDefaultsWidget" - socket={@socket} - props={runtime_defaults_widget_props(assigns)} - events={ - %{ - "changeRuntimeEnvironment" => "change_runtime_environment", - "saveRuntimeEnvironment" => "save_runtime_environment" - } - } - fallback_title="Interactive runtime defaults panel temporarily unavailable" - fallback_detail="Use the server-rendered runtime defaults panel below while the richer setup widget is unavailable." - > -
-
-

- Runtime defaults -

-

- {runtime_environment_description(@runtime_environment_mode)} -

-
- -
- <.icon name="hero-x-circle-mini" class="size-5" /> - {@runtime_save_error} -
- - <.form - id="setup-runtime-environment-form" - for={@runtime_environment_form} - phx-change="change_runtime_environment" - phx-submit="save_runtime_environment" - class="space-y-3" - > - <.input - id="setup-runtime-environment-select" - field={@runtime_environment_form[:mode]} - type="select" - label="Runtime environment" - options={@runtime_environment_options} - /> - - <.input - :if={@runtime_environment_mode == :local} - id="setup-runtime-workspace-root" - field={@runtime_environment_form[:workspace_root]} - type="text" - label="Default workspace root" - placeholder="/absolute/path/used/for/new/local-imports" - autocomplete="off" - /> - - - - -
-
-
- Install flavor -
-
- {deployment_mode_label(@deployment_mode)} -
-
- -
-
- Admin email -
-
- {@owner_email} -
-
- -
-
- Saved runtime default -
-
- {saved_runtime_environment_label(@persisted_default_environment)} -
-

- {saved_runtime_environment_note( - @persisted_default_environment, - @persisted_workspace_root - )} -

-
- -
-
- Saved choice -
-
- {selected_start_path_label(@selected_start_path)} -
-
-
-
- - -

- {selected_start_path_note(@selected_start_path, @deployment_mode)} -

-
- -
-
- <.icon name="hero-exclamation-triangle-mini" class="size-5" /> - {@diagnostic} -
- -
- <.icon name="hero-x-circle-mini" class="size-5" /> - {@save_error} -
- - <.vue_surface - id="setup-start-path-selector" - component="SetupStartPathSelectorWidget" - socket={@socket} - props={start_path_selector_props(assigns)} - events={%{"chooseStartPath" => "choose_start_path"}} - fallback_title="Interactive start-path chooser temporarily unavailable" - fallback_detail="Use the server-rendered setup choices below while the richer chooser is unavailable." - > -
-
-
-
-

{option.title}

- - {choice_badge_label(option.id, @selected_start_path, option.recommended?)} - -
-

{option.summary}

-

{option.detail}

-
- - -
-
- - -
-
-
-

Choose GitHub repositories

- - Optional follow-up - -
-

- {github_repository_panel_summary( - @github_pat_capture_state, - @github_repository_listing_report, - @github_project_import_report - )} -

-

- Pick one or more linked GitHub repositories to import into the control plane now, or finish onboarding and come back later. Each managed repository can keep its own workspace binding after import. -

-
- -
-
-
-

Save a GitHub PAT for this install

- - Required for repo listing - -
-

- {@github_pat_capture_state.detail} -

-

- Save a deployment-local PAT now and setup will retry repository access automatically. The token is stored as an encrypted secret for this install, not in plaintext setup state. -

-
- -
- <.icon name="hero-x-circle-mini" class="size-5" /> - {@github_pat_save_error} -
- -
- <.icon name="hero-exclamation-triangle-mini" class="size-5" /> - {@github_pat_capture_state.encryption_preflight_detail} -
- - <.form - id="setup-github-pat-form" - for={@github_pat_form} - phx-submit="save_github_pat" - class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]" - > - <.input - id="setup-github-pat-input" - field={@github_pat_form[:value]} - type="password" - label="GitHub PAT" - placeholder="github_pat_..." - autocomplete="off" - disabled={@buttons_disabled? or not @github_pat_capture_state.encryption_ready?} - /> - - - - -
- {@github_pat_capture_state.remediation} -
- -

- Stored as encrypted SecretRef {@github_pat_capture_state.secret_ref_name}. -

-
- -
-
-

- Repository selection unlocks after this install has a saved GitHub PAT and setup refreshes access. -

-

- Once the token is saved successfully, the repository picker will appear here automatically. -

-
-
- - <.vue_surface - :if={ - not github_repository_selector_deferred?( - @github_pat_capture_state, - @github_project_import_report - ) - } - id="setup-github-repository-selector" - component="SetupGitHubRepositorySelectorWidget" - socket={@socket} - props={github_repository_selector_props(assigns)} - events={ - %{ - "selectRepository" => "select_github_repository", - "refreshRepositories" => "refresh_github_repository_listing", - "importRepository" => "import_selected_github_repository" - } - } - fallback_title="Interactive GitHub repository picker temporarily unavailable" - fallback_detail="Use the server-rendered selector below while the richer picker is unavailable." - > -
-
-
-

- {github_repository_panel_title()} -

- - {github_repository_panel_badge_label()} - -
-

- {github_repository_panel_summary( - @github_pat_capture_state, - @github_repository_listing_report, - @github_project_import_report - )} -

-

- {github_repository_panel_detail()} -

-

- {github_repository_widget_boundary_note()} -

-
- -
-
-

Repository access

-

- {github_listing_status_label(@github_repository_listing_report)} -

-

- {@github_repository_listing_report.detail} -

-
-
-

Saved selection

-

- {github_selected_repository_summary(@github_selected_repositories)} -

-

- {github_repository_count_label(@github_repository_options)} -

-
-
-

Import state

-

- {github_import_status_label(@github_project_import_report)} -

-

- {github_import_detail(@github_project_import_report)} -

-
-
- -
- {@github_repository_listing_report.remediation} -
- -
- <.form - id="setup-github-repository-fallback-filter-form" - for={@github_repository_filters_form} - phx-change="filter_github_repositories" - class="grid gap-3 xl:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]" - > - <.input - id="setup-github-repository-fallback-account-filter" - field={@github_repository_filters_form[:owner]} - type="select" - label="GitHub account" - options={github_repository_account_filter_options(@github_repository_account_options)} - disabled={@buttons_disabled? or @github_repository_options == []} - /> - <.input - id="setup-github-repository-fallback-search" - field={@github_repository_filters_form[:query]} - type="text" - label="Search linked repositories" - placeholder="owner/repository" - phx-debounce="200" - disabled={@buttons_disabled? or @github_repository_options == []} - /> - - -
-
-

- Linked GitHub repositories -

-

- {github_repository_count_label(@github_repository_options)} -

-
- -
-

- {github_filtered_repository_count_label( - @github_filtered_repository_options, - @github_repository_options - )} -

-

- {github_selected_results_summary(@github_selected_repositories)} -

-
- -
- - -
- {github_repository_filter_empty_state( - @github_filtered_repository_options, - @github_repository_options - )} -
-
-
- - <.form - id="setup-github-repository-selector-fallback-form" - for={%{}} - phx-submit="import_selected_github_repository" - class="grid gap-3 lg:grid-cols-[auto_auto] lg:justify-end" - > - - - - - - -
- -
-
-

- {github_import_detail(@github_project_import_report)} -

-

- You can open the imported managed repository now or continue to the dashboard. -

-
- - <.link - :if={github_project_path(@github_project_import_report)} - id="setup-github-import-fallback-open-repo" - navigate={github_project_path(@github_project_import_report)} - class="btn btn-sm btn-outline" - > - Open managed repo - -
-
- -
- -
-
-
-
-

Continue into the app

- Available now -
-

- {completion_summary(@selected_start_path)} -

-

- GitHub setup, repo import, runtime defaults, and repo-level workspace bindings remain editable after onboarding. -

-
- - -
-
-
-
-
-
- """ - end - - defp route_setup(socket, %SystemConfig{} = config, diagnostic, config_load_error?) do - cond do - config.onboarding_step < 3 -> - {:ok, push_navigate(socket, to: ~p"/welcome")} - - is_nil(socket.assigns[:current_user]) -> - {:ok, push_navigate(socket, to: ~p"/welcome")} - - config.onboarding_completed -> - {:ok, push_navigate(socket, to: ~p"/dashboard")} - - true -> - {:ok, assign_start_surface(socket, config, diagnostic, config_load_error?)} - end - end - - defp assign_start_surface(socket, %SystemConfig{} = config, diagnostic, buttons_disabled?) do - deployment_mode = DeploymentMode.current() - owner_email = owner_email(config.onboarding_state, socket.assigns[:current_user]) - selected_start_path = selected_start_path(config.onboarding_state) - - onboarding_state = - if show_github_repository_selector?(selected_start_path) do - hydrate_github_setup_state(config.onboarding_state, owner_email) - else - normalize_keyed_map(config.onboarding_state) - end - - github_repository_listing_report = github_repository_listing_report(onboarding_state) - github_project_import_report = github_project_import_report(onboarding_state) - - github_pat_capture_state = - github_pat_capture_state(onboarding_state, github_repository_listing_report) - - github_selected_repositories = - github_selected_repositories( - onboarding_state, - github_repository_listing_report, - github_project_import_report - ) - - socket - |> assign(:deployment_mode, deployment_mode) - |> assign(:onboarding_state, onboarding_state) - |> assign(:owner_email, owner_email) - |> assign(:selected_start_path, selected_start_path) - |> assign(:persisted_default_environment, config.default_environment) - |> assign(:persisted_workspace_root, config.workspace_root) - |> assign(:runtime_environment_options, @runtime_environment_options) - |> assign(:start_options, start_options(deployment_mode)) - |> assign(:diagnostic, diagnostic) - |> assign(:buttons_disabled?, buttons_disabled?) - |> assign(:github_repository_listing_report, github_repository_listing_report) - |> assign(:github_repository_options, GitHubRepositoryListing.repository_options(github_repository_listing_report)) - |> assign( - :github_repository_full_names, - GitHubRepositoryListing.repository_full_names(github_repository_listing_report) - ) - |> assign_github_repository_filters() - |> assign(:github_pat_capture_state, github_pat_capture_state) - |> assign(:github_selected_repositories, github_selected_repositories) - |> assign(:github_project_import_report, github_project_import_report) - |> assign_github_pat_form() - |> assign_runtime_environment_form(runtime_environment_form_values(config)) - |> assign(:github_pat_save_error, nil) - |> assign(:runtime_save_error, nil) - |> assign(:save_error, nil) - end - - defp assign_github_pat_form(socket) do - assign(socket, :github_pat_form, to_form(%{"value" => ""}, as: :github_pat)) - end - - defp assign_github_repository_filters(socket, filter_params \\ nil) do - repository_options = Map.get(socket.assigns, :github_repository_options, []) - - current_filter_values = - filter_params || Map.get(socket.assigns, :github_repository_filter_values, %{}) - - normalized_filter_values = - normalize_github_repository_filter_values(current_filter_values, repository_options) - - socket - |> assign( - :github_repository_filters_form, - to_form(normalized_filter_values, as: :repository_filters) - ) - |> assign(:github_repository_filter_values, normalized_filter_values) - |> assign(:github_repository_account_options, github_repository_account_options(repository_options)) - |> assign( - :github_filtered_repository_options, - filter_github_repository_options(repository_options, normalized_filter_values) - ) - end - - defp start_options(:desktop) do - [ - %{ - id: :local_repo, - title: "Add local repo", - summary: "Start from a folder on this machine.", - detail: "Use this when your source of truth already lives on your desktop.", - recommended?: true, - button_class: "btn-primary" - }, - %{ - id: :github, - title: "Connect GitHub", - summary: "Use a hosted repository and GitHub-backed workflows.", - detail: "Choose this if GitHub should stay your primary source-control path.", - recommended?: false, - button_class: "btn-outline" - }, - %{ - id: :later, - title: "Do this later", - summary: "Keep moving and come back when you are ready to attach a repo.", - detail: "This keeps the setup path simple while you finish the rest of the product wiring.", - recommended?: false, - button_class: "btn-ghost" - } - ] - end - - defp start_options(:cloud) do - [ - %{ - id: :github, - title: "Connect GitHub", - summary: "Use GitHub as the first source-control path for this install.", - detail: "This is the cleanest starting point for a hosted deployment.", - recommended?: true, - button_class: "btn-primary" - }, - %{ - id: :later, - title: "Do this later", - summary: "Keep moving and connect source control when you need it.", - detail: "You can defer GitHub setup until the first feature that depends on it.", - recommended?: false, - button_class: "btn-outline" - } - ] - end - - defp persist_start_path(deployment_mode, start_path, owner_context) do - SystemConfig.update_onboarding_state(fn current_state -> - current_state = - current_state - |> normalize_keyed_map() - |> maybe_hydrate_github_setup_state(start_path, owner_context) - - existing_step_state = current_state |> fetch_step_state(3) |> normalize_keyed_map() - - updated_step_state = - existing_step_state - |> Map.put("start_path", Atom.to_string(start_path)) - |> Map.put("deployment_mode", Atom.to_string(deployment_mode)) - |> Map.put("validated_note", start_path_note(start_path)) - - {:ok, Map.put(current_state, "3", updated_step_state)} - end) - end - - defp persist_runtime_environment(deployment_mode, runtime_report) do - runtime_state = EnvironmentDefaults.serialize_for_state(runtime_report) - config_updates = EnvironmentDefaults.system_config_updates(runtime_report) - - SystemConfig.update(fn %SystemConfig{} = config -> - current_state = normalize_keyed_map(config.onboarding_state) - existing_step_state = current_state |> fetch_step_state(3) |> normalize_keyed_map() - - updated_step_state = - existing_step_state - |> Map.put("deployment_mode", Atom.to_string(deployment_mode)) - |> Map.put("runtime_environment", runtime_state) - |> Map.put("runtime_environment_note", runtime_environment_note(runtime_report)) - - {:ok, - %SystemConfig{ - config - | default_environment: config_updates.default_environment, - workspace_root: config_updates.workspace_root, - onboarding_state: Map.put(current_state, "3", updated_step_state) - }} - end) - end - - defp persist_github_repository_selection(selected_repositories) do - SystemConfig.update_onboarding_state(fn current_state -> - current_state = normalize_keyed_map(current_state) - existing_step_state = current_state |> fetch_step_state(@github_setup_step) |> normalize_keyed_map() - - updated_step_state = - existing_step_state - |> maybe_put_selected_repositories(selected_repositories) - |> Map.put( - "repository_selection_note", - github_repository_selection_note(selected_repositories) - ) - - {:ok, Map.put(current_state, Integer.to_string(@github_setup_step), updated_step_state)} - end) - end - - defp refresh_github_repository_listing(owner_context) do - SystemConfig.update_onboarding_state(fn current_state -> - current_state = - current_state - |> normalize_keyed_map() - |> hydrate_github_setup_state(owner_context, force?: true) - - existing_step_state = current_state |> fetch_step_state(@github_setup_step) |> normalize_keyed_map() - - previous_report = - existing_step_state - |> map_get(:repository_listing, "repository_listing") - |> GitHubRepositoryListing.from_state() - - listing_report = GitHubRepositoryListing.run(previous_report, current_state) - - selected_repositories = - github_selected_repositories( - current_state, - listing_report, - existing_step_state |> map_get(:project_import, "project_import") |> ProjectImport.from_state() - ) - - updated_step_state = - existing_step_state - |> Map.put( - "repository_listing", - GitHubRepositoryListing.serialize_for_state(listing_report) - ) - |> maybe_put_selected_repositories(selected_repositories) - |> Map.put( - "repository_selection_note", - github_repository_selection_note(selected_repositories) - ) - - {:ok, Map.put(current_state, Integer.to_string(@github_setup_step), updated_step_state)} - end) - end - - defp persist_github_project_import(selected_repositories, owner_context) do - SystemConfig.update_onboarding_state(fn current_state -> - current_state = - current_state - |> normalize_keyed_map() - |> hydrate_github_setup_state(owner_context, force?: true) - - existing_step_state = current_state |> fetch_step_state(@github_setup_step) |> normalize_keyed_map() - - previous_listing = - existing_step_state - |> map_get(:repository_listing, "repository_listing") - |> GitHubRepositoryListing.from_state() - - listing_report = GitHubRepositoryListing.run(previous_listing, current_state) - - previous_import = - existing_step_state - |> map_get(:project_import, "project_import") - |> ProjectImport.from_state() - - {project_import_report, batch_import_report} = - run_github_project_import_batch(previous_import, selected_repositories, current_state) - - remaining_selected_repositories = - github_selected_repositories_after_import(selected_repositories, batch_import_report) - - updated_step_state = - existing_step_state - |> Map.put( - "repository_listing", - GitHubRepositoryListing.serialize_for_state(listing_report) - ) - |> Map.put("project_import", ProjectImport.serialize_for_state(project_import_report)) - |> Map.put("project_import_batch", serialize_github_project_import_batch(batch_import_report)) - |> maybe_put_selected_repositories(remaining_selected_repositories) - |> Map.put( - "repository_selection_note", - github_repository_selection_note(remaining_selected_repositories) - ) - - {:ok, Map.put(current_state, Integer.to_string(@github_setup_step), updated_step_state)} - end) - end - - defp complete_setup(deployment_mode, selected_start_path) do - SystemConfig.update(fn %SystemConfig{} = config -> - current_state = normalize_keyed_map(config.onboarding_state) - existing_step_state = current_state |> fetch_step_state(3) |> normalize_keyed_map() - - completed_start_path = - selected_start_path || - existing_step_state |> map_get(:start_path, "start_path") |> normalize_start_path() || - :later - - updated_step_state = - existing_step_state - |> Map.put("start_path", Atom.to_string(completed_start_path)) - |> Map.put("deployment_mode", Atom.to_string(deployment_mode)) - |> Map.put("validated_note", start_path_note(completed_start_path)) - |> Map.put( - "completion_note", - "Setup completed. Optional follow-up work remains available from the signed-in app." - ) - - {:ok, - %SystemConfig{ - config - | onboarding_completed: true, - onboarding_step: max(config.onboarding_step, 4), - onboarding_state: Map.put(current_state, "3", updated_step_state) - }} - end) - end - - defp github_repository_listing_report(onboarding_state) do - onboarding_state - |> fetch_step_state(@github_setup_step) - |> map_get(:repository_listing, "repository_listing") - |> GitHubRepositoryListing.from_state() - |> case do - nil -> GitHubRepositoryListing.run(nil, onboarding_state) - report -> report - end - end - - defp github_project_import_report(onboarding_state) do - step_state = fetch_step_state(onboarding_state, @github_setup_step) - - case step_state - |> map_get(:project_import_batch, "project_import_batch") - |> github_project_import_batch_from_state() do - nil -> - step_state - |> map_get(:project_import, "project_import") - |> ProjectImport.from_state() - |> github_project_import_batch_from_single_report() - - batch_report -> - batch_report - end - end - - defp github_selected_repositories(onboarding_state, listing_report, _project_import_report) do - step_state = fetch_step_state(onboarding_state, @github_setup_step) - - persisted_selections = - step_state - |> map_get(:selected_repositories, "selected_repositories") - |> normalize_repository_list() - - persisted_selection = - step_state - |> map_get(:selected_repository, "selected_repository") - |> normalize_optional_string() - - available_repositories = GitHubRepositoryListing.repository_full_names(listing_report) - - persisted_selections - |> Kernel.++(List.wrap(persisted_selection)) - |> Enum.uniq() - |> Enum.filter(&repository_selection_available?(&1, available_repositories)) - end - - defp show_github_repository_selector?(:github), do: true - defp show_github_repository_selector?(_selected_start_path), do: false - - defp github_repository_panel_title, do: "Choose GitHub repositories" - defp github_repository_panel_badge_label, do: "Optional follow-up" - - defp github_repository_panel_summary(github_pat_capture_state, listing_report, project_import_report) do - cond do - github_project_import_ready?(project_import_report) -> - github_import_detail(project_import_report) - - github_repository_selector_deferred?(github_pat_capture_state, project_import_report) -> - "Save a deployment-local GitHub PAT first, then repository selection will unlock automatically." - - GitHubRepositoryListing.blocked?(listing_report) -> - listing_report.detail - - true -> - "Pick one or more linked GitHub repositories and import them into the control plane." - end - end - - defp github_repository_panel_detail do - "Pick one or more linked GitHub repositories to import into the control plane now, or finish onboarding and come back later. Each imported managed repository can later keep its own workspace binding." - end - - defp github_repository_widget_boundary_note do - "LiveView still owns PAT capture, persistence, and completion; this widget only makes repository follow-up easier to scan and resume." - end - - defp github_pat_capture_visible?(%{required?: true}, project_import_report), - do: not github_project_import_ready?(project_import_report) - - defp github_pat_capture_visible?(_github_pat_capture_state, _project_import_report), do: false - - defp github_repository_selector_deferred?(github_pat_capture_state, project_import_report), - do: github_pat_capture_visible?(github_pat_capture_state, project_import_report) - - defp github_repository_selector_props(assigns) do - %{ - panelTitle: github_repository_panel_title(), - panelBadgeLabel: github_repository_panel_badge_label(), - panelSummary: - github_repository_panel_summary( - assigns.github_pat_capture_state, - assigns.github_repository_listing_report, - assigns.github_project_import_report - ), - panelDetail: github_repository_panel_detail(), - boundaryNote: github_repository_widget_boundary_note(), - listingStatus: github_listing_status(assigns.github_repository_listing_report), - listingDetail: assigns.github_repository_listing_report.detail, - listingRemediation: assigns.github_repository_listing_report.remediation, - listingErrorType: assigns.github_repository_listing_report.error_type, - listingCheckedAt: checked_at_label(assigns.github_repository_listing_report.checked_at), - accountOptions: assigns.github_repository_account_options, - repositoryCountLabel: github_repository_count_label(assigns.github_repository_options), - repositoryOptions: Enum.map(assigns.github_repository_options, &github_repository_option_props/1), - selectedRepositories: assigns.github_selected_repositories, - importStatus: github_import_status(assigns.github_project_import_report), - importDetail: github_import_detail(assigns.github_project_import_report), - importRemediation: github_import_remediation(assigns.github_project_import_report), - importErrorType: github_import_error_type(assigns.github_project_import_report), - importSelectedRepositories: github_project_import_selected_repositories(assigns.github_project_import_report), - importProjectId: github_project_id(assigns.github_project_import_report), - importProjectDisplayName: github_project_display_name(assigns.github_project_import_report), - importProjectPath: github_project_path(assigns.github_project_import_report), - importMode: github_project_import_mode(assigns.github_project_import_report), - buttonsDisabled: assigns.buttons_disabled? - } - end - - defp start_path_selector_props(assigns) do - %{ - options: - Enum.map( - assigns.start_options, - &start_path_selector_option_props(&1, assigns.selected_start_path, assigns.buttons_disabled?) - ), - selectedStartPath: - case assigns.selected_start_path do - nil -> nil - selected_start_path -> Atom.to_string(selected_start_path) - end - } - end - - defp start_path_selector_option_props(option, selected_start_path, buttons_disabled?) do - %{ - id: Atom.to_string(option.id), - title: option.title, - summary: option.summary, - detail: option.detail, - badgeLabel: choice_badge_label(option.id, selected_start_path, option.recommended?), - buttonLabel: choice_button_label(option.id, selected_start_path), - buttonClass: option.button_class, - selected: selected_start_path == option.id, - disabled: buttons_disabled? or selected_start_path == option.id - } - end - - defp runtime_defaults_widget_props(assigns) do - form_values = Map.get(assigns, :runtime_environment_form_values, %{"mode" => "cloud", "workspace_root" => ""}) - - %{ - runtimeDescription: runtime_environment_description(assigns.runtime_environment_mode), - saveError: assigns.runtime_save_error, - form: %{ - mode: Map.get(form_values, "mode", "cloud"), - workspaceRoot: Map.get(form_values, "workspace_root", "") - }, - runtimeOptions: - Enum.map(assigns.runtime_environment_options, fn {label, value} -> - %{label: label, value: value} - end), - installFlavor: deployment_mode_label(assigns.deployment_mode), - ownerEmail: assigns.owner_email, - savedRuntimeLabel: saved_runtime_environment_label(assigns.persisted_default_environment), - savedRuntimeNote: - saved_runtime_environment_note( - assigns.persisted_default_environment, - assigns.persisted_workspace_root - ), - selectedStartPathLabel: selected_start_path_label(assigns.selected_start_path), - buttonsDisabled: assigns.buttons_disabled? - } - end - - defp github_pat_capture_state(onboarding_state, listing_report) do - pat_path = github_pat_path(onboarding_state) - encryption_status = Encryption.config_status() - - %{ - required?: GitHubRepositoryListing.blocked?(listing_report) and github_pat_capture_required?(pat_path), - detail: github_pat_capture_detail(pat_path, listing_report), - remediation: github_pat_capture_remediation(pat_path, listing_report), - error_type: github_pat_capture_error_type(pat_path), - secret_ref_name: ServiceCredentials.service_secret_ref_name(:pat), - encryption_ready?: encryption_status == :ready, - encryption_preflight_detail: github_pat_encryption_preflight_detail(encryption_status), - encryption_error_type: github_pat_encryption_error_type(encryption_status) - } - end - - defp github_pat_path(onboarding_state) do - onboarding_state - |> fetch_step_state(4) - |> map_get(:github_credentials, "github_credentials") - |> GitHubCredentialChecks.from_state() - |> case do - %{paths: paths} when is_list(paths) -> Enum.find(paths, &(&1.path == :pat)) - _other -> nil - end - end - - defp github_pat_capture_required?(%{status: :ready, repository_access: :confirmed}), do: false - defp github_pat_capture_required?(_pat_path), do: true - - defp github_pat_capture_detail(%{detail: detail}, _listing_report) when is_binary(detail), do: detail - defp github_pat_capture_detail(_pat_path, %{detail: detail}) when is_binary(detail), do: detail - - defp github_pat_capture_detail(_pat_path, _listing_report) do - "Save a deployment-local GitHub PAT to validate repository access for this install." - end - - defp github_pat_capture_remediation(%{remediation: remediation}, _listing_report) - when is_binary(remediation), - do: remediation - - defp github_pat_capture_remediation(_pat_path, %{remediation: remediation}) - when is_binary(remediation), - do: remediation - - defp github_pat_capture_remediation(_pat_path, _listing_report), do: nil - - defp github_pat_capture_error_type(%{error_type: error_type}) when is_binary(error_type), - do: error_type - - defp github_pat_capture_error_type(_pat_path), do: nil - - defp github_pat_encryption_preflight_detail(:ready), do: nil - - defp github_pat_encryption_preflight_detail(:invalid) do - "Encrypted secret storage is unavailable because the configured `JIDO_CODE_SECRET_REF_ENCRYPTION_KEY` is invalid. Replace it with a base64-encoded 32-byte key and restart JidoCode before saving a GitHub PAT." - end - - defp github_pat_encryption_preflight_detail(:missing) do - "Encrypted secret storage is unavailable in this running JidoCode process. Set `JIDO_CODE_SECRET_REF_ENCRYPTION_KEY` to a base64-encoded 32-byte key and restart JidoCode before saving a GitHub PAT." - end - - defp github_pat_encryption_error_type(:ready), do: nil - defp github_pat_encryption_error_type(:invalid), do: "secret_encryption_key_invalid" - defp github_pat_encryption_error_type(:missing), do: "secret_encryption_unavailable" - - defp github_repository_option_props(repository_option) do - %{ - id: Map.get(repository_option, :id), - fullName: Map.get(repository_option, :full_name), - owner: Map.get(repository_option, :owner), - name: Map.get(repository_option, :name) - } - end - - defp github_listing_status(%{status: :ready}), do: "ready" - defp github_listing_status(%{status: :blocked}), do: "blocked" - defp github_listing_status(_report), do: "blocked" - - defp github_listing_status_label(%{status: :ready}), do: "Ready" - defp github_listing_status_label(_report), do: "Needs attention" - - defp github_repository_account_options(repository_options) do - repository_options - |> Enum.map(&Map.get(&1, :owner)) - |> Enum.map(&normalize_optional_string/1) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - |> Enum.sort_by(&String.downcase/1) - end - - defp github_repository_account_filter_options(account_options) do - [{"All accounts", ""} | Enum.map(account_options, &{&1, &1})] - end - - defp normalize_github_repository_filter_values(filter_params, repository_options) - when is_map(filter_params) do - account_options = github_repository_account_options(repository_options) - - owner = - filter_params - |> map_get(:owner, "owner", "") - |> normalize_optional_string() - |> case do - selected_owner -> - if selected_owner in account_options, do: selected_owner, else: "" - end - - query = - filter_params - |> map_get(:query, "query", "") - |> normalize_optional_string() - |> case do - nil -> "" - normalized_query -> normalized_query - end - - %{"owner" => owner, "query" => query} - end - - defp normalize_github_repository_filter_values(_filter_params, _repository_options) do - %{"owner" => "", "query" => ""} - end - - defp filter_github_repository_options(repository_options, filter_values) when is_list(repository_options) do - owner_filter = - filter_values - |> map_get(:owner, "owner", "") - |> normalize_optional_string() - - query_filter = - filter_values - |> map_get(:query, "query", "") - |> normalize_optional_string() - - Enum.filter(repository_options, fn repository -> - github_repository_matches_filter?(repository, owner_filter, query_filter) - end) - end - - defp filter_github_repository_options(_repository_options, _filter_values), do: [] - - defp github_repository_matches_filter?(repository, owner_filter, query_filter) do - owner_matches? = - is_nil(owner_filter) or Map.get(repository, :owner) == owner_filter - - query_matches? = - case query_filter do - nil -> - true - - normalized_query -> - repository - |> github_repository_search_text() - |> String.contains?(String.downcase(normalized_query)) - end - - owner_matches? and query_matches? - end - - defp github_repository_search_text(repository) do - repository - |> then(fn repository_option -> - [ - Map.get(repository_option, :full_name), - Map.get(repository_option, :owner), - Map.get(repository_option, :name) - ] - end) - |> Enum.filter(&is_binary/1) - |> Enum.join(" ") - |> String.downcase() - end - - defp github_repository_count_label([]), do: "No linked repositories are currently available." - - defp github_repository_count_label(repository_options) do - "#{length(repository_options)} linked repositories available for import." - end - - defp github_filtered_repository_count_label(filtered_repository_options, repository_options) do - filtered_count = length(filtered_repository_options) - total_count = length(repository_options) - - cond do - total_count == 0 -> - "No linked repositories are available yet." - - filtered_count == total_count -> - "Showing all #{total_count} linked repositories." - - true -> - "Showing #{filtered_count} of #{total_count} linked repositories." - end - end - - defp github_selected_repository_summary([]), do: "Not selected" - - defp github_selected_repository_summary([selected_repository]), - do: selected_repository - - defp github_selected_repository_summary(selected_repositories) when is_list(selected_repositories) do - "#{length(selected_repositories)} repositories selected" - end - - defp github_selected_results_summary([]), do: "Selected: none" - - defp github_selected_results_summary([selected_repository]), - do: "Selected: #{selected_repository}" - - defp github_selected_results_summary(selected_repositories) when is_list(selected_repositories) do - "Selected: #{length(selected_repositories)} repositories" - end - - defp github_repository_filter_empty_state([], []), - do: "No linked repositories are currently available." - - defp github_repository_filter_empty_state([], _repository_options), - do: "No linked repositories match the current filter." - - defp github_repository_filter_empty_state(_filtered_repository_options, _repository_options), - do: nil - - defp github_import_button_label([]), do: "Select repositories to import" - defp github_import_button_label([_selected_repository]), do: "Import selected repository" - - defp github_import_button_label(selected_repositories) when is_list(selected_repositories) do - "Import #{length(selected_repositories)} selected repositories" - end - - defp github_project_import_ready?(%{status: :ready}), do: true - defp github_project_import_ready?(_report), do: false - - defp github_import_status(%{status: :ready}), do: "ready" - defp github_import_status(%{status: :blocked}), do: "blocked" - defp github_import_status(_report), do: "idle" - - defp github_import_status_label(%{status: :ready}), do: "Imported" - defp github_import_status_label(%{status: :blocked}), do: "Needs attention" - defp github_import_status_label(_report), do: "Not started" - - defp github_import_detail(%{detail: detail}) when is_binary(detail), do: detail - defp github_import_detail(_report), do: "No repository import has run yet." - - defp github_import_remediation(%{remediation: remediation}) when is_binary(remediation), - do: remediation - - defp github_import_remediation(_report), do: nil - - defp github_import_error_type(%{error_type: error_type}) when is_binary(error_type), do: error_type - defp github_import_error_type(_report), do: nil - - defp github_project_import_mode(%{project_paths: [_project_path], last_project_import: last_project_import}) - when is_map(last_project_import), - do: github_project_import_mode(last_project_import) - - defp github_project_import_mode(%{project_record: %{import_mode: import_mode}}) - when import_mode in [:created, :existing], - do: Atom.to_string(import_mode) - - defp github_project_import_mode(_report), do: nil - - defp github_project_id(%{project_paths: [%{managed_repo_id: managed_repo_id}]}) - when is_binary(managed_repo_id), - do: managed_repo_id - - defp github_project_id(%{project_record: %{id: id}}) when is_binary(id), do: id - defp github_project_id(_report), do: nil - - defp github_project_display_name(%{project_paths: [%{repository_full_name: repository_full_name}]}) - when is_binary(repository_full_name) do - repository_name(repository_full_name) - end - - defp github_project_display_name(%{imported_repositories: imported_repositories}) - when is_list(imported_repositories) and length(imported_repositories) > 1 do - "#{length(imported_repositories)} repositories imported" - end - - defp github_project_display_name(%{selected_repositories: selected_repositories}) - when is_list(selected_repositories) and length(selected_repositories) > 1 do - "#{length(selected_repositories)} repositories selected" - end - - defp github_project_display_name(%{project_record: %{name: name}}) when is_binary(name), do: name - - defp github_project_display_name(%{selected_repository: selected_repository}) - when is_binary(selected_repository) do - repository_name(selected_repository) - end - - defp github_project_display_name(_report), do: nil - - defp github_project_path(%{project_paths: [%{path: path}]}) when is_binary(path), do: path - - defp github_project_path(project_import_report) do - case github_project_id(project_import_report) do - nil -> nil - managed_repo_id -> ~p"/repos/#{managed_repo_id}" - end - end - - defp github_project_import_flash(%{detail: detail}, _selected_repositories) - when is_binary(detail) and detail != "" do - detail - end - - defp github_project_import_flash(project_import_report, selected_repositories) do - case project_import_report do - %{status: :ready} -> - case selected_repositories do - [selected_repository] -> - "Imported #{selected_repository} into the managed-repository control plane." - - repositories when is_list(repositories) -> - "Imported #{length(repositories)} GitHub repositories into the managed-repository control plane." - end - - _other -> - "GitHub repository import could not complete." - end - end - - defp github_repository_selection_note([selected_repository]) do - "GitHub repository #{selected_repository} selected for optional import." - end - - defp github_repository_selection_note(selected_repositories) - when is_list(selected_repositories) and selected_repositories != [] do - "#{length(selected_repositories)} GitHub repositories selected for optional import." - end - - defp github_repository_selection_note(_selected_repositories) do - "GitHub repository selection cleared." - end - - defp maybe_put_selected_repositories(step_state, selected_repositories) do - normalized_selected_repositories = normalize_repository_list(selected_repositories) - - step_state - |> maybe_put_selected_repository(List.first(normalized_selected_repositories)) - |> case do - updated_step_state when normalized_selected_repositories == [] -> - Map.delete(updated_step_state, "selected_repositories") - - updated_step_state -> - Map.put(updated_step_state, "selected_repositories", normalized_selected_repositories) - end - end - - defp maybe_put_selected_repository(step_state, selected_repository) when is_binary(selected_repository), - do: Map.put(step_state, "selected_repository", selected_repository) - - defp maybe_put_selected_repository(step_state, _selected_repository), - do: Map.delete(step_state, "selected_repository") - - defp resolve_selected_repositories(params, current_selected_repositories) do - case extract_repository_selections(params) do - {:toggle, selected_repository} -> - toggle_selected_repository(current_selected_repositories, selected_repository) - - {:replace, selected_repositories} -> - selected_repositories - - :none -> - normalize_repository_list(current_selected_repositories) - end - end - - defp extract_repository_selections(%{"repository_selection" => selection_params}) - when is_map(selection_params), - do: extract_repository_selections(selection_params) - - defp extract_repository_selections(%{"repository_full_names" => repository_full_names}), - do: {:replace, normalize_repository_list(repository_full_names)} - - defp extract_repository_selections(%{repository_full_names: repository_full_names}), - do: {:replace, normalize_repository_list(repository_full_names)} - - defp extract_repository_selections(%{"repository_full_name" => repository_full_name}) do - case normalize_optional_string(repository_full_name) do - nil -> :none - selected_repository -> {:toggle, selected_repository} - end - end - - defp extract_repository_selections(%{repository_full_name: repository_full_name}), - do: extract_repository_selections(%{"repository_full_name" => repository_full_name}) - - defp extract_repository_selections(_params), do: :none - - defp valid_github_repository_selection?(selected_repositories, []), - do: Enum.all?(normalize_repository_list(selected_repositories), &is_binary/1) - - defp valid_github_repository_selection?(selected_repositories, available_repositories) do - selected_repositories - |> normalize_repository_list() - |> Enum.all?(&(&1 in available_repositories)) - end - - defp repository_selection_available?(selected_repository, []), do: is_binary(selected_repository) - - defp repository_selection_available?(selected_repository, available_repositories), - do: selected_repository in available_repositories - - defp toggle_selected_repository(current_selected_repositories, selected_repository) do - normalized_current_selection = normalize_repository_list(current_selected_repositories) - - if selected_repository in normalized_current_selection do - Enum.reject(normalized_current_selection, &(&1 == selected_repository)) - else - normalized_current_selection ++ [selected_repository] - end - end - - defp normalize_repository_list(selected_repositories) when is_list(selected_repositories) do - selected_repositories - |> Enum.map(&normalize_optional_string/1) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - defp normalize_repository_list(selected_repository) when is_binary(selected_repository), - do: normalize_repository_list([selected_repository]) - - defp normalize_repository_list(_selected_repositories), do: [] - - defp github_project_import_selected_repositories(%{selected_repositories: selected_repositories}) - when is_list(selected_repositories), - do: normalize_repository_list(selected_repositories) - - defp github_project_import_selected_repositories(project_import_report), - do: normalize_repository_list(ProjectImport.selected_repository(project_import_report)) - - defp github_project_import_batch_from_single_report(nil), do: nil - - defp github_project_import_batch_from_single_report(project_import_report) do - selected_repositories = github_project_import_selected_repositories(project_import_report) - imported_repositories = if github_project_import_ready?(project_import_report), do: selected_repositories, else: [] - - %{ - checked_at: Map.get(project_import_report, :checked_at), - status: Map.get(project_import_report, :status, :idle), - selected_repositories: selected_repositories, - imported_repositories: imported_repositories, - project_paths: github_project_paths_from_report(project_import_report), - detail: Map.get(project_import_report, :detail), - remediation: Map.get(project_import_report, :remediation), - error_type: Map.get(project_import_report, :error_type), - last_project_import: project_import_report - } - end - - defp github_project_import_batch_from_state(nil), do: nil - - defp github_project_import_batch_from_state(state) when is_map(state) do - %{ - checked_at: - state - |> map_get(:checked_at, "checked_at") - |> normalize_optional_datetime() || - DateTime.utc_now() |> DateTime.truncate(:second), - status: - state - |> map_get(:status, "status") - |> normalize_project_import_batch_status(), - selected_repositories: - state - |> map_get(:selected_repositories, "selected_repositories") - |> normalize_repository_list(), - imported_repositories: - state - |> map_get(:imported_repositories, "imported_repositories") - |> normalize_repository_list(), - project_paths: - state - |> map_get(:project_paths, "project_paths", []) - |> normalize_github_project_paths(), - detail: state |> map_get(:detail, "detail") |> normalize_optional_string(), - remediation: state |> map_get(:remediation, "remediation") |> normalize_optional_string(), - error_type: state |> map_get(:error_type, "error_type") |> normalize_optional_string(), - last_project_import: - state - |> map_get(:last_project_import, "last_project_import") - |> ProjectImport.from_state() - } - end - - defp github_project_import_batch_from_state(_state), do: nil - - defp serialize_github_project_import_batch(batch_import_report) do - %{ - "checked_at" => batch_import_report.checked_at && DateTime.to_iso8601(batch_import_report.checked_at), - "status" => Atom.to_string(batch_import_report.status), - "selected_repositories" => batch_import_report.selected_repositories, - "imported_repositories" => batch_import_report.imported_repositories, - "project_paths" => - Enum.map(batch_import_report.project_paths, fn project_path -> - %{ - "repository_full_name" => project_path.repository_full_name, - "managed_repo_id" => project_path.managed_repo_id, - "path" => project_path.path - } - end), - "detail" => batch_import_report.detail, - "remediation" => batch_import_report.remediation, - "error_type" => batch_import_report.error_type, - "last_project_import" => ProjectImport.serialize_for_state(batch_import_report.last_project_import) - } - end - - defp run_github_project_import_batch(previous_import, selected_repositories, onboarding_state) do - selected_repositories = normalize_repository_list(selected_repositories) - - import_reports = - Enum.map(selected_repositories, fn selected_repository -> - previous_report = - if ProjectImport.selected_repository(previous_import) == selected_repository do - previous_import - else - nil - end - - {selected_repository, ProjectImport.run(previous_report, selected_repository, onboarding_state)} - end) - - {last_project_import(import_reports), github_project_import_batch(import_reports)} - end - - defp github_selected_repositories_after_import(_selected_repositories, %{status: :ready}), do: [] - - defp github_selected_repositories_after_import(selected_repositories, batch_import_report) do - imported_repositories = - batch_import_report - |> Map.get(:imported_repositories, []) - |> normalize_repository_list() - - selected_repositories - |> normalize_repository_list() - |> Enum.reject(&(&1 in imported_repositories)) - end - - defp github_project_import_batch(import_reports) do - selected_repositories = Enum.map(import_reports, fn {selected_repository, _report} -> selected_repository end) - - successful_imports = - Enum.filter(import_reports, fn {_selected_repository, report} -> - github_project_import_ready?(report) - end) - - imported_repositories = - Enum.map(successful_imports, fn {selected_repository, _report} -> selected_repository end) - - failed_imports = Enum.reject(import_reports, fn import -> import in successful_imports end) - last_import_report = last_project_import(import_reports) - - %{ - checked_at: - last_import_report && - Map.get(last_import_report, :checked_at, DateTime.utc_now() |> DateTime.truncate(:second)), - status: github_project_import_batch_status(successful_imports, failed_imports), - selected_repositories: selected_repositories, - imported_repositories: imported_repositories, - project_paths: - Enum.flat_map(successful_imports, fn {selected_repository, report} -> - github_project_paths_from_report(report, selected_repository) - end), - detail: - github_project_import_batch_detail( - selected_repositories, - imported_repositories, - failed_imports, - last_import_report - ), - remediation: github_project_import_batch_remediation(failed_imports, last_import_report), - error_type: github_project_import_batch_error_type(failed_imports, last_import_report), - last_project_import: last_import_report - } - end - - defp last_project_import([]), do: nil - defp last_project_import(import_reports), do: import_reports |> List.last() |> elem(1) - - defp github_project_import_batch_status(_successful_imports, []), do: :ready - defp github_project_import_batch_status([], _failed_imports), do: :blocked - defp github_project_import_batch_status(_successful_imports, _failed_imports), do: :blocked - - defp normalize_project_import_batch_status("ready"), do: :ready - defp normalize_project_import_batch_status(:ready), do: :ready - defp normalize_project_import_batch_status("blocked"), do: :blocked - defp normalize_project_import_batch_status(:blocked), do: :blocked - defp normalize_project_import_batch_status(_status), do: :idle - - defp github_project_import_batch_detail( - selected_repositories, - _imported_repositories, - [], - last_import_report - ) - when length(selected_repositories) <= 1 do - Map.get(last_import_report || %{}, :detail, "No repository import has run yet.") - end - - defp github_project_import_batch_detail( - _selected_repositories, - imported_repositories, - [], - _last_import_report - ) do - "Imported #{length(imported_repositories)} GitHub repositories into the managed-repository control plane." - end - - defp github_project_import_batch_detail( - selected_repositories, - imported_repositories, - _failed_imports, - last_import_report - ) - when imported_repositories == [] and length(selected_repositories) <= 1 do - Map.get(last_import_report || %{}, :detail, "GitHub repository import could not complete.") - end - - defp github_project_import_batch_detail( - selected_repositories, - imported_repositories, - _failed_imports, - _last_import_report - ) - when imported_repositories != [] do - "Imported #{length(imported_repositories)} of #{length(selected_repositories)} GitHub repositories into the managed-repository control plane." - end - - defp github_project_import_batch_detail( - selected_repositories, - _imported_repositories, - _failed_imports, - _last_import_report - ) do - "GitHub repository imports could not complete for #{length(selected_repositories)} selected repositories." - end - - defp github_project_import_batch_remediation([], _last_import_report), do: nil - - defp github_project_import_batch_remediation([{_selected_repository, failed_report} | _rest], _last_import_report) do - github_import_remediation(failed_report) - end - - defp github_project_import_batch_error_type([], _last_import_report), do: nil - - defp github_project_import_batch_error_type([{_selected_repository, failed_report}], _last_import_report) do - github_import_error_type(failed_report) - end - - defp github_project_import_batch_error_type(_failed_imports, _last_import_report), - do: "github_repository_import_batch_incomplete" - - defp github_project_paths_from_report(project_import_report, selected_repository_override \\ nil) do - case github_project_path(project_import_report) do - nil -> - [] - - path -> - [ - %{ - repository_full_name: - selected_repository_override || - ProjectImport.selected_repository(project_import_report), - managed_repo_id: github_project_id(project_import_report), - path: path - } - ] - end - end - - defp normalize_github_project_paths(project_paths) when is_list(project_paths) do - Enum.flat_map(project_paths, fn project_path -> - repository_full_name = - project_path - |> map_get(:repository_full_name, "repository_full_name") - |> normalize_optional_string() - - managed_repo_id = - project_path - |> map_get(:managed_repo_id, "managed_repo_id") - |> normalize_optional_string() - - path = - project_path - |> map_get(:path, "path") - |> normalize_optional_string() - - if is_nil(repository_full_name) or is_nil(managed_repo_id) or is_nil(path) do - [] - else - [ - %{ - repository_full_name: repository_full_name, - managed_repo_id: managed_repo_id, - path: path - } - ] - end - end) - end - - defp normalize_github_project_paths(_project_paths), do: [] - - defp repository_name(selected_repository) when is_binary(selected_repository) do - selected_repository - |> String.split("/") - |> List.last() - end - - defp checked_at_label(%DateTime{} = checked_at), do: Calendar.strftime(checked_at, "%Y-%m-%d %H:%M UTC") - defp checked_at_label(_checked_at), do: "Not checked yet" - - defp selected_start_path(onboarding_state) do - onboarding_state - |> fetch_step_state(3) - |> map_get(:start_path, "start_path") - |> normalize_start_path() - end - - defp owner_email(onboarding_state, current_user) do - onboarding_state - |> fetch_step_state(2) - |> map_get(:owner_email, "owner_email") - |> normalize_optional_string() - |> case do - nil -> current_user_email(current_user) - owner_email -> owner_email - end - end - - defp current_user_email(%{email: email}), do: email - defp current_user_email(_current_user), do: "Admin account" - - defp setup_description(:desktop), - do: "Your admin account is ready. Pick the first path you want this desktop install to guide next." - - defp setup_description(:cloud), - do: "Your admin account is ready. Pick the first path you want this install to guide next." - - defp runtime_environment_description(:cloud), - do: - "Cloud defaults keep new imports on Sprite-backed execution until a managed repository is rebound to its own workspace settings." - - defp runtime_environment_description(:local), - do: - "Local defaults seed new imports from a workspace root on this machine until each managed repository carries its own workspace binding." - - defp deployment_mode_label(:desktop), do: "Desktop" - defp deployment_mode_label(:cloud), do: "Cloud" - - defp saved_runtime_environment_label(:local), do: "Local" - defp saved_runtime_environment_label(:sprite), do: "Cloud" - defp saved_runtime_environment_label(_default_environment), do: "Cloud" - - defp saved_runtime_environment_note(:local, workspace_root) when is_binary(workspace_root) do - "New local imports will seed repo workspace paths from #{workspace_root} until each managed repository is rebound." - end - - defp saved_runtime_environment_note(:local, _workspace_root) do - "Local defaults are selected, but the default workspace root still needs to be supplied for new local imports." - end - - defp saved_runtime_environment_note(:sprite, _workspace_root) do - "Cloud defaults map to Sprite-backed execution and do not bind repository-local workspace paths." - end - - defp saved_runtime_environment_note(_default_environment, _workspace_root) do - "Cloud defaults map to Sprite-backed execution and do not bind repository-local workspace paths." - end - - defp selected_start_path_label(nil), do: "Not chosen yet" - defp selected_start_path_label(start_path), do: start_path_title(start_path) - - defp selected_start_path_note(nil, :desktop), - do: "Pick a default path now. You can still change it later once more setup moves into the app." - - defp selected_start_path_note(nil, :cloud), - do: "Pick a default path now. You can still change it later once more setup moves into the app." - - defp selected_start_path_note(:local_repo, _deployment_mode), - do: - "Local repositories are the default path for this install until you choose something else, and each managed repo can later keep its own workspace binding." - - defp selected_start_path_note(:github, _deployment_mode), - do: - "GitHub is saved as the default source-control path for this install, and imported repos can later keep their own workspace bindings." - - defp selected_start_path_note(:later, _deployment_mode), - do: "Repo setup is deferred for now. You can come back and choose a source-control path later." - - defp completion_summary(nil), - do: "Finish onboarding now. You can defer source-control setup until you are inside the app." - - defp completion_summary(:local_repo), - do: "Local repo is saved as your preferred next step. You can finish onboarding and attach it from inside the app." - - defp completion_summary(:github), - do: - "GitHub is saved as your preferred next step. You can finish onboarding, connect it from inside the app, and later bind each managed repository to its own workspace." - - defp completion_summary(:later), - do: "Repository setup is deferred for now. You can finish onboarding and come back later." - - defp choice_badge_label(option_id, selected_start_path, _recommended?) when option_id == selected_start_path, - do: "Saved" - - defp choice_badge_label(_option_id, _selected_start_path, true), do: "Recommended" - defp choice_badge_label(_option_id, _selected_start_path, _recommended?), do: nil - - defp choice_button_label(option_id, selected_start_path) when option_id == selected_start_path, - do: "Saved" - - defp choice_button_label(option_id, _selected_start_path), do: start_path_title(option_id) - - defp start_path_title(:local_repo), do: "Add local repo" - defp start_path_title(:github), do: "Connect GitHub" - defp start_path_title(:later), do: "Do this later" - - defp start_path_note(:local_repo), do: "Local repo path selected." - defp start_path_note(:github), do: "GitHub path selected." - defp start_path_note(:later), do: "Repo setup deferred for now." - - defp runtime_environment_note(%{mode: :local, workspace_root: workspace_root}) - when is_binary(workspace_root) do - "Local runtime defaults saved for #{Path.expand(workspace_root)} as the seed root for new local imports." - end - - defp runtime_environment_note(%{mode: :local}), - do: "Local runtime defaults saved." - - defp runtime_environment_note(%{mode: :cloud}), - do: "Cloud runtime defaults saved." - - defp runtime_environment_title(:local), do: "Local" - defp runtime_environment_title(:cloud), do: "Cloud" - defp runtime_environment_title(_mode), do: "Cloud" - - defp runtime_environment_error_message(runtime_report) do - runtime_report - |> EnvironmentDefaults.blocked_checks() - |> Enum.map(fn check -> - [Map.get(check, :detail), Map.get(check, :remediation)] - |> Enum.filter(&is_binary/1) - |> Enum.join(" ") - end) - |> Enum.reject(&(&1 == "")) - |> Enum.join(" ") - |> case do - "" -> "Runtime defaults could not be validated." - message -> message - end - end - - defp secret_persistence_error_message(%{ - message: message, - recovery_instruction: recovery_instruction - }) - when is_binary(message) and is_binary(recovery_instruction) do - "#{message} #{recovery_instruction}" - end - - defp secret_persistence_error_message(%{message: message}) when is_binary(message), do: message - defp secret_persistence_error_message(_typed_error), do: "GitHub PAT could not be saved." - - defp assign_runtime_environment_form(socket, runtime_params) do - normalized_form_values = normalize_runtime_environment_form_values(runtime_params) - - socket - |> assign(:runtime_environment_form, to_form(normalized_form_values, as: :runtime_environment)) - |> assign(:runtime_environment_form_values, normalized_form_values) - |> assign( - :runtime_environment_mode, - normalized_form_values |> Map.get("mode", "cloud") |> normalize_runtime_environment_mode() - ) - end - - defp runtime_environment_form_values(%SystemConfig{} = config) do - %{ - "mode" => config.default_environment |> runtime_environment_mode() |> Atom.to_string(), - "workspace_root" => config.workspace_root || "" - } - end - - defp normalize_runtime_environment_form_values(runtime_params) when is_map(runtime_params) do - %{ - "mode" => - runtime_params - |> map_get(:mode, "mode", "cloud") - |> normalize_runtime_environment_mode() - |> Atom.to_string(), - "workspace_root" => - runtime_params - |> map_get(:workspace_root, "workspace_root", "") - |> normalize_optional_string() - |> case do - nil -> "" - workspace_root -> workspace_root - end - } - end - - defp normalize_runtime_environment_form_values(_runtime_params), - do: %{"mode" => "cloud", "workspace_root" => ""} - - defp runtime_environment_mode(:local), do: :local - defp runtime_environment_mode(:sprite), do: :cloud - defp runtime_environment_mode(_default_environment), do: :cloud - - defp maybe_hydrate_github_setup_state(current_state, :github, owner_context), - do: hydrate_github_setup_state(current_state, owner_context) - - defp maybe_hydrate_github_setup_state(current_state, _start_path, _owner_context), - do: current_state - - defp hydrate_github_setup_state(onboarding_state, owner_context, opts \\ []) do - current_state = normalize_keyed_map(onboarding_state) - force? = Keyword.get(opts, :force?, false) - - cond do - not is_binary(owner_context) -> - current_state - - force? or missing_github_credential_snapshot?(current_state) -> - step_state = current_state |> fetch_step_state(4) |> normalize_keyed_map() - - github_credentials = - step_state - |> map_get(:github_credentials, "github_credentials") - |> GitHubCredentialChecks.run(owner_context) - |> GitHubCredentialChecks.serialize_for_state() - - updated_step_state = - step_state - |> Map.put("github_credentials", github_credentials) - |> Map.put( - "validated_note", - "GitHub credential readiness captured for optional repository follow-up." - ) - - Map.put(current_state, "4", updated_step_state) - - true -> - current_state - end - end - - defp missing_github_credential_snapshot?(onboarding_state) when is_map(onboarding_state) do - onboarding_state - |> fetch_step_state(4) - |> map_get(:github_credentials, "github_credentials") - |> GitHubCredentialChecks.from_state() - |> case do - %{paths: [_ | _]} -> false - _other -> true - end - end - - defp missing_github_credential_snapshot?(_onboarding_state), do: true - - defp current_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - Actor.operator_actor(%{ - "id" => Map.get(user, :id) || Map.get(user, "id"), - "email" => Map.get(user, :email) || Map.get(user, "email") - }) - - _other -> - Actor.operator_actor(%{ - "id" => "system:setup-live", - "email" => "setup-live@system.local" - }) - end - end - - defp normalize_runtime_environment_mode("local"), do: :local - defp normalize_runtime_environment_mode(:local), do: :local - defp normalize_runtime_environment_mode("cloud"), do: :cloud - defp normalize_runtime_environment_mode(:cloud), do: :cloud - defp normalize_runtime_environment_mode(_mode), do: :cloud - - defp fetch_step_state(onboarding_state, onboarding_step) when is_map(onboarding_state) do - step_key = Integer.to_string(onboarding_step) - Map.get(onboarding_state, step_key) || Map.get(onboarding_state, onboarding_step) || %{} - end - - defp fetch_step_state(_onboarding_state, _onboarding_step), do: %{} - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) and is_atom(atom_key) do - Map.get(map, atom_key, Map.get(map, string_key, default)) - end - - defp map_get(_map, _atom_key, _string_key, default), do: default - - defp normalize_start_path(start_path) when start_path in @start_paths, do: start_path - - defp normalize_start_path(start_path) when is_binary(start_path) do - case String.trim(start_path) do - "local_repo" -> :local_repo - "github" -> :github - "later" -> :later - _other -> nil - end - end - - defp normalize_start_path(_start_path), do: nil - - defp normalize_keyed_map(map) when is_map(map) do - Enum.reduce(map, %{}, fn - {key, value}, acc when is_atom(key) -> - Map.put(acc, Atom.to_string(key), value) - - {key, value}, acc when is_binary(key) -> - Map.put(acc, key, value) - - {key, value}, acc -> - Map.put(acc, to_string(key), value) - end) - end - - defp normalize_keyed_map(_map), do: %{} - - defp normalize_optional_string(nil), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized -> normalized - end - end - - defp normalize_optional_string(_value), do: nil - - defp normalize_optional_datetime(%DateTime{} = datetime), do: datetime - - defp normalize_optional_datetime(%NaiveDateTime{} = datetime) do - case DateTime.from_naive(datetime, "Etc/UTC") do - {:ok, converted_datetime} -> converted_datetime - {:error, _reason} -> nil - end - end - - defp normalize_optional_datetime(value) when is_binary(value) do - case DateTime.from_iso8601(value) do - {:ok, datetime, _offset} -> - datetime - - {:error, _reason} -> - case NaiveDateTime.from_iso8601(value) do - {:ok, naive_datetime} -> normalize_optional_datetime(naive_datetime) - {:error, _reason} -> nil - end - end - end - - defp normalize_optional_datetime(_value), do: nil + use JidoCodeWeb.ResetLive, title: "Setup" end diff --git a/lib/jido_code_web/live/work_item_detail_live.ex b/lib/jido_code_web/live/work_item_detail_live.ex index a20dec68..e7b087a6 100644 --- a/lib/jido_code_web/live/work_item_detail_live.ex +++ b/lib/jido_code_web/live/work_item_detail_live.ex @@ -1,641 +1,3 @@ defmodule JidoCodeWeb.WorkItemDetailLive do - # covers: architecture.memory_graph_workflow_and_operator_expansion.governed_surfaces_host_memory_context - # covers: architecture.memory_graph_workflow_and_operator_expansion.operator_memory_actions_use_product_owned_boundaries - # covers: architecture.memory_graph_workflow_and_operator_expansion.cross_graph_navigation_connects_memory_code_and_governed_history - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_actions_preserve_freshness_supersession_and_provenance - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_promotions_create_governed_follow_up - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.operator_memory_actions_are_available_from_canonical_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_and_governed_surfaces_host_bounded_memory_context - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.canonical_routes_remain_product_and_governed - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.cross_graph_navigation_stays_consistent_across_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.memory_aware_workflow_and_governed_follow_up_use_product_projections - use JidoCodeWeb, :live_view - - import JidoCodeWeb.GovernedMemoryHelpers - - alias JidoCode.Control.{Actor, RepoBridge} - alias JidoCode.Governance.Decision - alias JidoCode.MemoryGraph.{FollowUpSurface, GovernedSurfaceContext, OperatorService, ProductService, SurfaceFeedback} - alias JidoCode.Operations.WorkItem - alias JidoCode.Orchestration.Run - - @surface_label "this governed work-item surface" - - @impl true - def mount(%{"id" => project_id, "work_item_id" => work_item_id}, _session, socket) do - socket = - socket - |> assign(:project_id, project_id) - |> assign(:memory_action_feedback, nil) - |> load_work_item_assigns(project_id, work_item_id) - - {:ok, socket} - end - - @impl true - def handle_event("recover_memory_graph", _params, socket) do - with %{managed_repo_id: managed_repo_id, workspace_path: workspace_path} <- socket.assigns.memory_context, - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id, - workspace_path when is_binary(workspace_path) <- workspace_path do - case ProductService.recover(managed_repo_id, workspace_path) do - {:ok, _result} -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_result( - memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason, diagnostics} -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - reason, - diagnostics, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - end - else - _other -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.recovery_error( - :memory_governed_scope_unavailable, - nil, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - end - end - - @impl true - def handle_event( - "validate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.validate(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, _result} -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :validate, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("validate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "invalidate_memory", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.invalidate(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, _result} -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :invalidate, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("invalidate_memory", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "promote_memory_follow_up", - %{"memory_iri" => memory_iri}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}}} = socket - ) do - case OperatorService.promote_follow_up(projection, memory_iri, memory_operator_opts(socket)) do - {:ok, %{result: %{work_item: work_item}, target: :work_item}} -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :promote_follow_up, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label, - work_item_id: work_item.id - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - end - - @impl true - def handle_event("promote_memory_follow_up", _params, socket), do: {:noreply, socket} - - @impl true - def handle_event( - "supersede_memory", - %{"memory_iri" => memory_iri, "decision_id" => decision_id}, - %{assigns: %{memory_context: %{surface: %{memories: projection}}, decisions: decisions}} = socket - ) do - case Enum.find(decisions, &(normalize_optional_string(map_get(&1, :id, "id")) == decision_id)) do - %Decision{} = decision -> - case OperatorService.supersede_with_governed_decision( - projection, - memory_iri, - decision, - memory_operator_opts(socket, decision_id: decision_id) - ) do - {:ok, _result} -> - refreshed_socket = refresh_work_item_assigns(socket) - - {:noreply, - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_result( - :supersede_with_governed_decision, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - )} - - {:error, reason} -> - {:noreply, assign_memory_action_error(socket, reason)} - end - - _other -> - {:noreply, assign_memory_action_error(socket, :governed_decision_not_found)} - end - end - - @impl true - def handle_event("supersede_memory", _params, socket), do: {:noreply, socket} - - @impl true - def render(assigns) do - ~H""" - -
-
-
-

- Governed work item -

-

- {display_string(@work_item.summary, "Work item detail")} -

-

- Work item not found -

-
- - <.link - :if={repo_route(@route_repo_id)} - navigate={repo_route(@route_repo_id)} - class="link link-primary text-sm" - > - Back to repository - -
- - <%= if @work_item do %> -
-
-

ID

-

{@work_item.id}

-
- -
-

Status

-

{display_atom(@work_item.status)}

-
- -
-

Priority

-

{display_atom(@work_item.priority)}

-
- -
-

Category

-

{display_string(@work_item.category)}

-
- -
-

Summary

-

{display_string(@work_item.summary)}

-
- -
-

- Recommended action -

- -
- -
-

- Managed repo -

-

- {display_string(@managed_repo_id)} -

-
-
- -
-
-

Governed run history

-

- Linked runs: {length(@related_runs)} -

-
- - <%= if @related_runs == [] do %> -

- No governed runs are currently linked to this work item. -

- <% else %> -
    -
  1. -
    -
    -

    - {display_string(map_get(run, :run_id, "run_id"))} -

    -

    - Status: {display_atom(map_get(run, :status, "status"))} -

    -
    - <.link - :if={run_route(@route_repo_id, map_get(run, :run_id, "run_id"))} - id={"work-item-detail-run-route-#{index}"} - navigate={run_route(@route_repo_id, map_get(run, :run_id, "run_id"))} - class="link link-primary text-xs" - > - Open run - -
    -
  2. -
- <% end %> -
- -
-
-

Work item memory context

-

- Memory state: {Map.get(@memory_context.graph, :state, :unavailable)} -

- <.operator_state_notice - :if={@memory_action_feedback} - id="work-item-detail-memory-action-feedback" - title="Work item memory update" - state={@memory_action_feedback} - kind={memory_feedback_kind(@memory_action_feedback)} - compact={true} - /> - <.memory_status_notice - :if={@memory_context.notice} - id="work-item-detail-memory-context-notice" - title="Work item memory status" - state={@memory_context.notice} - kind={Map.get(@memory_context, :notice_kind, :warning)} - recovery={Map.get(@memory_context, :recovery)} - recover_event="recover_memory_graph" - recover_id="work-item-detail-memory-recover" - /> -
- - <%= if @memory_context.surface do %> -
-
-

- {@memory_context.surface.label} -

-

- Memory: {@memory_context.surface.memory_count} | Provenance: {@memory_context.surface.provenance_count} -

-
- -
-

- Memory-aware follow-up -

-

- {@memory_follow_up_preview.summary} -

-

- Recommended action: {@memory_follow_up_preview.recommended_action_label} | Priority: {@memory_follow_up_preview.priority} | Urgency: {@memory_follow_up_preview.urgency} -

-

- Selected memory kinds: {Enum.join(@memory_follow_up_preview.memory_kinds, ", ")} -

- <.link - :if={@memory_follow_up_preview.route} - id="work-item-detail-memory-follow-up-preview-route" - navigate={@memory_follow_up_preview.route} - class="link link-primary text-xs" - > - {@memory_follow_up_preview.route_label} - -
- -
-

- Durable memories -

- - <%= if @memory_context.surface.memories.items == [] do %> -

- No durable memories currently point at this work item. -

- <% else %> -
    -
  1. -

    - {memory_item_kind(item)}: {memory_item_content(item)} -

    -

    - Freshness: {memory_item_freshness(item)} | Decision status: {memory_item_decision_status(item)} -

    -
    - - - -
    - <.memory_link_groups dom_prefix={"work-item-detail-memory-#{index}"} item={item} /> -
  2. -
- <% end %> -
- - - -
-

- Workflow provenance -

- - <%= if @memory_context.surface.provenance.items == [] do %> -

- No workflow provenance currently points at this work item. -

- <% else %> -
    -
  1. -

    - {provenance_item_kind(item)}: {provenance_item_label(item)} -

    -

    - Revision: {provenance_item_revision(item)} -

    - <.memory_link_groups dom_prefix={"work-item-detail-memory-provenance-#{index}"} item={item} /> -
  2. -
- <% end %> -
-
- <% end %> -
- <% else %> -
-

- No governed work item with id {@work_item_id} - is available on this managed-repository route. -

-
- <% end %> -
-
- """ - end - - defp load_work_item_assigns(socket, project_id, work_item_id) do - case load_work_item_state(project_id, work_item_id) do - {:ok, state} -> assign_work_item_state(socket, state) - {:error, :not_found} -> assign_missing_work_item(socket, project_id, work_item_id) - end - end - - defp refresh_work_item_assigns(socket) do - load_work_item_assigns(socket, socket.assigns.project_id, socket.assigns.work_item_id) - end - - defp load_work_item_state(project_id, work_item_id) do - with {:ok, scope} <- RepoBridge.repo_scope(project_id), - managed_repo_id when is_binary(managed_repo_id) <- managed_repo_id(scope), - {:ok, [%WorkItem{} = work_item]} <- - WorkItem.read( - query: [filter: [id: work_item_id, managed_repo_id: managed_repo_id], limit: 1], - actor: Actor.operator_actor() - ) do - route_repo_id = route_repo_id(scope) || managed_repo_id - workspace_path = load_repo_workspace_path(scope) - - memory_context = - GovernedSurfaceContext.load_governed_detail( - scope, - :work_item, - work_item, - managed_repo_id: managed_repo_id, - workspace_path: workspace_path, - current_route: work_item_route(route_repo_id, work_item.id) - ) - - {:ok, - %{ - project_id: project_id, - scope: scope, - work_item: work_item, - related_runs: load_related_runs(managed_repo_id, work_item.id), - decisions: load_related_decisions(managed_repo_id, work_item.id), - memory_context: memory_context, - memory_follow_up_preview: load_memory_follow_up_preview(memory_context, route_repo_id, work_item) - }} - else - _other -> {:error, :not_found} - end - end - - defp assign_work_item_state(socket, state) do - socket - |> assign(:project_id, state.project_id) - |> assign(:work_item, state.work_item) - |> assign(:work_item_id, state.work_item.id) - |> assign(:managed_repo_id, managed_repo_id(state.scope)) - |> assign(:route_repo_id, route_repo_id(state.scope) || managed_repo_id(state.scope)) - |> assign(:related_runs, state.related_runs) - |> assign(:decisions, state.decisions) - |> assign(:memory_context, state.memory_context) - |> assign(:memory_follow_up_preview, state.memory_follow_up_preview) - end - - defp assign_missing_work_item(socket, project_id, work_item_id) do - socket - |> assign(:project_id, project_id) - |> assign(:work_item, nil) - |> assign(:work_item_id, work_item_id) - |> assign(:managed_repo_id, nil) - |> assign(:route_repo_id, normalize_optional_string(project_id)) - |> assign(:related_runs, []) - |> assign(:decisions, []) - |> assign(:memory_context, nil) - |> assign(:memory_follow_up_preview, nil) - end - - defp load_related_runs(managed_repo_id, work_item_id) do - case Run.read( - query: [filter: [managed_repo_id: managed_repo_id, work_item_id: work_item_id], sort: [started_at: :desc]], - actor: Actor.operator_actor() - ) do - {:ok, runs} -> runs - _other -> [] - end - end - - defp load_related_decisions(managed_repo_id, work_item_id) do - case Decision.read( - query: [filter: [managed_repo_id: managed_repo_id, work_item_id: work_item_id], sort: [decided_at: :desc]], - actor: Actor.operator_actor() - ) do - {:ok, decisions} -> decisions - _other -> [] - end - end - - defp load_memory_follow_up_preview(%{surface: %{memories: projection}}, route_repo_id, %WorkItem{} = work_item) do - FollowUpSurface.preview( - projection, - route: work_item_memory_route(route_repo_id, work_item.id), - category: "governed_follow_up" - ) - end - - defp load_memory_follow_up_preview(_memory_context, _route_repo_id, _work_item), do: nil - - defp assign_memory_action_error(socket, reason) do - refreshed_socket = refresh_work_item_assigns(socket) - - assign( - refreshed_socket, - :memory_action_feedback, - SurfaceFeedback.action_error( - reason, - graph: memory_context_graph(refreshed_socket), - surface_label: @surface_label - ) - ) - end - - defp memory_operator_opts(socket, extra_opts \\ []) do - memory_operator_base_opts( - socket, - work_item_id: socket.assigns.work_item && socket.assigns.work_item.id - ) ++ extra_opts - end - - defp work_item_memory_route(repo_id, work_item_id) do - memory_anchor_route(work_item_route(repo_id, work_item_id), "#work-item-detail-memory-context") - end + use JidoCodeWeb.ResetLive, title: "Work Item Detail" end diff --git a/lib/jido_code_web/live/workbench_live.ex b/lib/jido_code_web/live/workbench_live.ex index 617d89f1..d3db7e01 100644 --- a/lib/jido_code_web/live/workbench_live.ex +++ b/lib/jido_code_web/live/workbench_live.ex @@ -1,1300 +1,3 @@ defmodule JidoCodeWeb.WorkbenchLive do - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.memory_graph_product_adoption.managed_repo_routes_host_memory_and_provenance_inspection - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.source_code_graph_product_adoption.managed_repo_routes_host_semantic_inspection - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: setup.onboarding.post_bootstrap_surfaces_adopt_control_plane_language - use JidoCodeWeb, :live_view - - import JidoCodeWeb.ManagedRepoInventoryComponents - - alias JidoCode.Orchestration.RunPubSub - alias JidoCode.ManagedRepoRoutes - alias JidoCodeWeb.OperatorShell - - alias JidoCode.Workbench.{ - FixWorkflowKickoff, - InventoryActionState, - InventorySurface, - IssueTriageWorkflowKickoff - } - - @fallback_row_id_prefix "workbench-row-" - @filter_validation_error_type "workbench_filter_values_invalid" - @filter_restore_validation_error_type "workbench_filter_restore_state_invalid" - @sort_validation_error_type "workbench_sort_order_fallback" - - @default_filter_values %{ - "project_id" => "all", - "work_state" => "all", - "freshness_window" => "any", - "sort_order" => "project_name_asc" - } - - @work_state_filter_options [ - {"Any issue or PR state", "all"}, - {"Issues open", "issues_open"}, - {"PRs open", "prs_open"}, - {"Issues and PRs open", "issues_and_prs_open"} - ] - - @freshness_filter_options [ - {"Any freshness", "any"}, - {"Active in last 24 hours", "active_24h"}, - {"Stale for 7+ days", "stale_7d"}, - {"Stale for 30+ days", "stale_30d"} - ] - - @sort_order_options [ - {"Repository name (A-Z)", "project_name_asc"}, - {"Backlog size (highest first)", "backlog_desc"}, - {"Backlog size (lowest first)", "backlog_asc"}, - {"Recent activity (most recent first)", "recent_activity_desc"}, - {"Recent activity (oldest first)", "recent_activity_asc"} - ] - - @default_project_filter_value Map.fetch!(@default_filter_values, "project_id") - @default_work_state_filter_value Map.fetch!(@default_filter_values, "work_state") - @default_freshness_filter_value Map.fetch!(@default_filter_values, "freshness_window") - @default_sort_order_value Map.fetch!(@default_filter_values, "sort_order") - @filter_state_query_keys ["project_id", "work_state", "freshness_window", "sort_order"] - @run_events_for_outcome_refresh MapSet.new([ - "run_started", - "run_completed", - "run_failed", - "run_cancelled" - ]) - - @impl true - def mount(params, _session, socket) do - initial_filter_values = initial_filter_values(params) - - socket = - socket - |> assign(:inventory_count, 0) - |> assign(:inventory_total_count, 0) - |> assign(:inventory_rows_all, []) - |> assign(:stale_warning, nil) - |> assign(:filter_validation_notice, nil) - |> assign(:sort_validation_notice, nil) - |> assign(:fix_workflow_kickoff_states, %{}) - |> assign(:issue_triage_workflow_kickoff_states, %{}) - |> assign(:recent_run_outcomes, %{}) - |> assign(:filter_values, initial_filter_values) - |> assign(:filter_form, to_filter_form(initial_filter_values)) - |> assign(:filter_chips, filter_chips(initial_filter_values, [])) - |> assign(:project_filter_options, project_filter_options([])) - |> stream(:inventory_rows, [], reset: true) - |> load_inventory() - |> maybe_subscribe_run_events() - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - case restored_filter_values(params, socket.assigns.inventory_rows_all) do - {:ok, filter_values} -> - socket = - socket - |> assign(:filter_validation_notice, nil) - |> apply_filters(filter_values) - - {:noreply, socket} - - {:error, field, invalid_value} -> - socket = - socket - |> assign( - :filter_validation_notice, - filter_restore_validation_notice(field, invalid_value) - ) - |> apply_filters(@default_filter_values) - - {:noreply, socket} - - :none -> - {:noreply, socket} - end - end - - @impl true - def handle_event("retry_fetch", _params, socket) do - {:noreply, load_inventory(socket)} - end - - @impl true - def handle_event("apply_filters", %{"filters" => filter_params}, socket) do - socket = - socket - |> apply_filter_event(filter_params) - |> push_filter_state_patch() - - {:noreply, socket} - end - - @impl true - def handle_event("apply_filters", _params, socket) do - socket = - socket - |> apply_invalid_filter_defaults("filters", "missing") - |> push_filter_state_patch() - - {:noreply, socket} - end - - @impl true - def handle_event("reset_filters", _params, socket) do - socket = - socket - |> assign(:filter_validation_notice, nil) - |> assign(:sort_validation_notice, nil) - |> apply_filters(@default_filter_values) - |> push_filter_state_patch() - - {:noreply, socket} - end - - @impl true - def handle_event( - "kickoff_fix_workflow", - %{"project_id" => project_id, "context_item_type" => context_item_type}, - socket - ) do - project_row = find_project_row(socket.assigns.inventory_rows_all, project_id) - return_to = current_workbench_path(socket.assigns.filter_values) - - kickoff_result = - FixWorkflowKickoff.kickoff( - project_row, - context_item_type, - initiating_actor(socket), - return_to: return_to - ) - - state_project_id = - case project_row do - %{} -> - project_row - |> map_get("id", :id) - |> normalize_optional_string() - - _other -> - nil - end || normalize_optional_string(project_id) || "unknown-project" - - socket = - socket - |> put_fix_workflow_kickoff_state( - state_project_id, - context_item_type, - kickoff_result, - return_to - ) - |> put_recent_run_outcome_from_kickoff(state_project_id, kickoff_result, return_to) - |> refresh_project_row(project_row) - - {:noreply, socket} - end - - @impl true - def handle_event( - "kickoff_issue_triage_workflow", - %{"project_id" => project_id, "context_item_type" => context_item_type}, - socket - ) do - project_row = find_project_row(socket.assigns.inventory_rows_all, project_id) - - kickoff_result = - IssueTriageWorkflowKickoff.kickoff( - project_row, - context_item_type, - initiating_actor(socket), - return_to: current_workbench_path(socket.assigns.filter_values) - ) - - state_project_id = - case project_row do - %{} -> - project_row - |> map_get("id", :id) - |> normalize_optional_string() - - _other -> - nil - end || normalize_optional_string(project_id) || "unknown-project" - - socket = - socket - |> put_issue_triage_workflow_kickoff_state( - state_project_id, - :issue, - kickoff_result, - current_workbench_path(socket.assigns.filter_values) - ) - |> put_recent_run_outcome_from_kickoff( - state_project_id, - kickoff_result, - current_workbench_path(socket.assigns.filter_values) - ) - |> refresh_project_row(project_row) - - {:noreply, socket} - end - - @impl true - def handle_info({:run_event, payload}, socket) do - event_name = - payload - |> map_get("event", :event) - |> normalize_optional_string() - - if MapSet.member?(@run_events_for_outcome_refresh, event_name) do - {:noreply, refresh_recent_run_outcomes(socket)} - else - {:noreply, socket} - end - end - - @impl true - def render(assigns) do - ~H""" - - <.single_pane_shell id="workbench-shell" breadcrumbs={workbench_breadcrumbs()} pane={workbench_pane()}> -
-
-

- Dense specialist mode -

- -

- Use - <.link navigate={ManagedRepoRoutes.dashboard_work_overview_path()} class="link link-primary"> - Dashboard Work - - for the lighter signed-in home when you do not need full filter and inventory controls. -

-
- - <.operator_state_notice - :if={@stale_warning} - id="workbench-stale-warning" - title="Workbench data may be stale" - state={@stale_warning} - > - <:actions> - - <.link - id="workbench-open-setup-recovery" - class="btn btn-sm btn-outline" - navigate={~p"/setup?step=7&reason=workbench_data_stale"} - > - Review setup diagnostics - - - - - <.operator_state_notice - :if={@filter_validation_notice} - id="workbench-filter-validation-notice" - dom_prefix="workbench-filter-validation" - title="Workbench filters were reset to defaults" - state={@filter_validation_notice} - kind={:notice} - /> - - <.operator_state_notice - :if={@sort_validation_notice} - id="workbench-sort-validation-notice" - dom_prefix="workbench-sort-validation" - title="Workbench sort fell back to default order" - state={@sort_validation_notice} - kind={:notice} - /> - - <.vue_surface - id="workbench-summary-widget" - component="WorkbenchSummaryWidget" - socket={@socket} - props={workbench_summary_widget_props(assigns)} - events={%{"resetFilters" => "reset_filters"}} - /> - -
- <.form - for={@filter_form} - id="workbench-filters-form" - phx-change="apply_filters" - phx-submit="apply_filters" - class="grid gap-3 lg:grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)_minmax(12rem,1fr)_minmax(14rem,1fr)]" - > - <.input - id="workbench-filter-project" - field={@filter_form[:project_id]} - type="select" - label="Managed repo" - options={@project_filter_options} - /> - <.input - id="workbench-filter-work-state" - field={@filter_form[:work_state]} - type="select" - label="Issue/PR state" - options={work_state_filter_options()} - /> - <.input - id="workbench-filter-freshness-window" - field={@filter_form[:freshness_window]} - type="select" - label="Freshness window" - options={freshness_filter_options()} - /> - <.input - id="workbench-filter-sort-order" - field={@filter_form[:sort_order]} - type="select" - label="Sort rows by" - options={sort_order_options()} - /> - - -
- - Managed repo: {@filter_chips.project} - - - State: {@filter_chips.work_state} - - - Freshness: {@filter_chips.freshness_window} - - - Sort: {@filter_chips.sort_order} - -
- -

- Showing {@inventory_count} of {@inventory_total_count} managed repositories. -

-
- -
- - - - - - - - - - - - - - - - - - - - - - -
Managed repoOpen issuesOpen PRsRecent activityLinks
- {empty_state_message(@inventory_total_count)} -
-

- {project.github_full_name} -

-

{project.name}

- <.managed_repo_hint_stack - row={project} - dom_prefix="workbench-project" - detail_path={project_detail_path(project, @filter_values)} - /> -
- {project.open_issue_count} - {project.open_pr_count} - {project.recent_activity_summary} -
-
-
- - <:footer_actions> - <.link - id="workbench-return-dashboard" - navigate={ManagedRepoRoutes.dashboard_work_overview_path()} - class="btn btn-sm btn-outline" - > - Return to Dashboard Work - - - - - -
- """ - end - - defp workbench_breadcrumbs do - [ - OperatorShell.breadcrumb(%{ - id: "workbench-breadcrumb-dashboard-work", - label: "Dashboard Work", - navigate: ManagedRepoRoutes.dashboard_work_overview_path() - }), - OperatorShell.breadcrumb(%{ - id: "workbench-breadcrumb-current", - label: "Workbench", - current?: true - }) - ] - end - - defp workbench_pane do - OperatorShell.pane(%{ - id: "workbench-pane", - title: "Managed repo specialist inventory", - summary: - "Workbench shares Dashboard Work's managed-repository inventory and governed triage model, with denser filters and scanning controls for specialist use." - }) - end - - defp load_inventory(socket) do - case InventorySurface.load(current_workbench_path(socket.assigns.filter_values)) do - {:ok, rows, recent_run_outcomes, stale_warning} -> - filter_values = - socket.assigns - |> Map.get(:filter_values, @default_filter_values) - |> normalize_filter_values() - |> validated_filter_values_or_default(rows) - - socket - |> assign(:inventory_rows_all, rows) - |> assign(:stale_warning, stale_warning) - |> assign(:recent_run_outcomes, recent_run_outcomes) - |> assign(:project_filter_options, project_filter_options(rows)) - |> apply_filters(filter_values) - - {:error, stale_warning} -> - filter_values = - socket.assigns - |> Map.get(:filter_values, @default_filter_values) - |> normalize_filter_values() - |> validated_filter_values_or_default([]) - - socket - |> assign(:inventory_count, 0) - |> assign(:inventory_total_count, 0) - |> assign(:inventory_rows_all, []) - |> assign(:stale_warning, stale_warning) - |> assign(:recent_run_outcomes, %{}) - |> assign(:sort_validation_notice, nil) - |> assign(:project_filter_options, project_filter_options([])) - |> assign(:filter_values, filter_values) - |> assign(:filter_form, to_filter_form(filter_values)) - |> assign(:filter_chips, filter_chips(filter_values, [])) - |> stream(:inventory_rows, [], reset: true) - end - end - - defp maybe_subscribe_run_events(socket) do - if connected?(socket) do - :ok = RunPubSub.subscribe_runs() - socket - else - socket - end - end - - defp refresh_recent_run_outcomes(socket) do - rows = Map.get(socket.assigns, :inventory_rows_all, []) - - filter_values = - socket.assigns - |> Map.get(:filter_values, @default_filter_values) - |> normalize_filter_values() - - socket - |> assign( - :recent_run_outcomes, - InventorySurface.load_recent_run_outcomes(rows, current_workbench_path(filter_values)) - ) - |> apply_filters(filter_values) - end - - defp apply_filter_event(socket, filter_params) do - filter_values = normalize_filter_values(filter_params) - - case validate_filter_values(filter_values, socket.assigns.inventory_rows_all) do - :ok -> - socket - |> assign(:filter_validation_notice, nil) - |> apply_filters(filter_values) - - {:error, field, invalid_value} -> - apply_invalid_filter_defaults(socket, field, invalid_value) - end - end - - defp apply_invalid_filter_defaults(socket, field, invalid_value) do - socket - |> assign(:filter_validation_notice, filter_validation_notice(field, invalid_value)) - |> apply_filters(@default_filter_values) - end - - defp push_filter_state_patch(socket) do - filter_values = - socket.assigns - |> Map.get(:filter_values, @default_filter_values) - |> normalize_filter_values() - - push_patch(socket, to: workbench_path_with_filter_values(filter_values)) - end - - defp workbench_summary_widget_props(assigns) do - filter_values = - assigns - |> Map.get(:filter_values, @default_filter_values) - |> normalize_filter_values() - - filter_chips = Map.get(assigns, :filter_chips, %{}) - - rows = Map.get(assigns, :inventory_rows_all, []) - filtered_rows = filter_rows(rows, filter_values) - {sorted_rows, _applied_filter_values, _sort_notice} = sort_rows_with_fallback(filtered_rows, filter_values) - - project_summaries = - Enum.map(Enum.take(sorted_rows, 5), fn row -> - outcome = - recent_run_outcome(Map.get(assigns, :recent_run_outcomes, %{}), Map.get(row, :id)) || %{} - - %{ - id: Map.get(row, :id), - githubFullName: Map.get(row, :github_full_name), - backlogCount: backlog_size(row), - recentActivitySummary: Map.get(row, :recent_activity_summary), - runOutcomeStatus: - outcome - |> map_get(:status, "status") - |> normalize_optional_string(), - requiresAttention: workbench_summary_attention?(row, outcome) - } - end) - - %{ - inventoryCount: Map.get(assigns, :inventory_count, 0), - inventoryTotalCount: Map.get(assigns, :inventory_total_count, 0), - attentionCount: Enum.count(project_summaries, & &1.requiresAttention), - filterChips: %{ - project: Map.get(filter_chips, :project), - workState: Map.get(filter_chips, :work_state), - freshnessWindow: Map.get(filter_chips, :freshness_window), - sortOrder: Map.get(filter_chips, :sort_order) - }, - projectSummaries: project_summaries, - resetVisible: filter_values != @default_filter_values - } - end - - defp workbench_summary_attention?(row, outcome) do - backlog_size(row) > 0 and - normalize_optional_string(map_get(outcome, :status, "status")) not in [nil, "completed"] - end - - defp apply_filters(socket, filter_values) do - rows = filter_rows(socket.assigns.inventory_rows_all, filter_values) - - {sorted_rows, applied_filter_values, sort_validation_notice} = - sort_rows_with_fallback(rows, filter_values) - - project_options = project_filter_options(socket.assigns.inventory_rows_all) - - socket - |> assign(:inventory_count, length(sorted_rows)) - |> assign(:inventory_total_count, length(socket.assigns.inventory_rows_all)) - |> assign(:sort_validation_notice, sort_validation_notice) - |> assign(:filter_values, applied_filter_values) - |> assign(:filter_form, to_filter_form(applied_filter_values)) - |> assign( - :filter_chips, - filter_chips(applied_filter_values, socket.assigns.inventory_rows_all) - ) - |> assign(:project_filter_options, project_options) - |> stream(:inventory_rows, sorted_rows, reset: true) - end - - defp filter_rows(rows, filter_values) do - now = DateTime.utc_now() - project_id = Map.fetch!(filter_values, "project_id") - work_state = Map.fetch!(filter_values, "work_state") - freshness_window = Map.fetch!(filter_values, "freshness_window") - - Enum.filter(rows, fn row -> - project_filter_match?(row, project_id) and - work_state_filter_match?(row, work_state) and - freshness_filter_match?(row, freshness_window, now) - end) - end - - defp project_filter_match?(_row, @default_project_filter_value), do: true - defp project_filter_match?(row, project_id), do: Map.get(row, :id) == project_id - - defp work_state_filter_match?(_row, @default_work_state_filter_value), do: true - - defp work_state_filter_match?(row, "issues_open"), - do: Map.get(row, :open_issue_count, 0) > 0 - - defp work_state_filter_match?(row, "prs_open"), - do: Map.get(row, :open_pr_count, 0) > 0 - - defp work_state_filter_match?(row, "issues_and_prs_open"), - do: Map.get(row, :open_issue_count, 0) > 0 and Map.get(row, :open_pr_count, 0) > 0 - - defp work_state_filter_match?(_row, _work_state), do: false - - defp freshness_filter_match?(_row, @default_freshness_filter_value, _now), do: true - - defp freshness_filter_match?(row, "active_24h", now), - do: recent_activity_within?(row, now, 24 * 60 * 60) - - defp freshness_filter_match?(row, "stale_7d", now), - do: stale_for_or_missing?(row, now, 7 * 24 * 60 * 60) - - defp freshness_filter_match?(row, "stale_30d", now), - do: stale_for_or_missing?(row, now, 30 * 24 * 60 * 60) - - defp freshness_filter_match?(_row, _freshness_window, _now), do: false - - defp recent_activity_within?(row, now, seconds) do - case row_recent_activity_at(row) do - %DateTime{} = activity_at -> - cutoff = DateTime.add(now, -seconds, :second) - DateTime.compare(activity_at, cutoff) in [:eq, :gt] - - _other -> - false - end - end - - defp stale_for_or_missing?(row, now, seconds) do - case row_recent_activity_at(row) do - %DateTime{} = activity_at -> - cutoff = DateTime.add(now, -seconds, :second) - DateTime.compare(activity_at, cutoff) in [:lt, :eq] - - _other -> - true - end - end - - defp row_recent_activity_at(row) do - row - |> Map.get(:recent_activity_at) - |> normalize_optional_datetime() - end - - defp normalize_filter_values(filter_values) when is_map(filter_values) do - %{ - "project_id" => - filter_values - |> map_get("project_id", :project_id) - |> normalize_optional_string() || @default_project_filter_value, - "work_state" => - filter_values - |> map_get("work_state", :work_state) - |> normalize_optional_string() || @default_work_state_filter_value, - "freshness_window" => - filter_values - |> map_get("freshness_window", :freshness_window) - |> normalize_optional_string() || @default_freshness_filter_value, - "sort_order" => - filter_values - |> map_get("sort_order", :sort_order) - |> normalize_optional_string() || @default_sort_order_value - } - end - - defp normalize_filter_values(_filter_values), do: @default_filter_values - - defp initial_filter_values(params) do - restored_values = extract_filter_state_params(params || %{}) - - if map_size(restored_values) == 0 do - @default_filter_values - else - @default_filter_values - |> Map.merge(restored_values) - |> normalize_filter_values() - end - end - - defp restored_filter_values(params, rows) do - restored_values = extract_filter_state_params(params) - - if map_size(restored_values) == 0 do - :none - else - filter_values = - @default_filter_values - |> Map.merge(restored_values) - |> normalize_filter_values() - - case validate_filter_values(filter_values, rows) do - :ok -> - {:ok, filter_values} - - {:error, field, invalid_value} -> - {:error, field, invalid_value} - end - end - end - - defp extract_filter_state_params(params) do - Enum.reduce(@filter_state_query_keys, %{}, fn key, acc -> - case Map.get(params, key) do - nil -> acc - value -> Map.put(acc, key, value) - end - end) - end - - defp validated_filter_values_or_default(filter_values, rows) do - case validate_filter_values(filter_values, rows) do - :ok -> filter_values - {:error, _field, _invalid_value} -> @default_filter_values - end - end - - defp validate_filter_values(filter_values, rows) do - valid_project_values = - rows - |> Enum.map(&Map.get(&1, :id)) - |> Enum.reject(&is_nil/1) - |> then(&MapSet.new([@default_project_filter_value | &1])) - - valid_work_state_values = @work_state_filter_options |> Enum.map(&elem(&1, 1)) |> MapSet.new() - - valid_freshness_values = - @freshness_filter_options |> Enum.map(&elem(&1, 1)) |> MapSet.new() - - valid_sort_order_values = - @sort_order_options |> Enum.map(&elem(&1, 1)) |> MapSet.new() - - with :ok <- - validate_filter_value( - "project_id", - Map.fetch!(filter_values, "project_id"), - valid_project_values - ), - :ok <- - validate_filter_value( - "work_state", - Map.fetch!(filter_values, "work_state"), - valid_work_state_values - ), - :ok <- - validate_filter_value( - "freshness_window", - Map.fetch!(filter_values, "freshness_window"), - valid_freshness_values - ), - :ok <- - validate_filter_value( - "sort_order", - Map.fetch!(filter_values, "sort_order"), - valid_sort_order_values - ) do - :ok - end - end - - defp validate_filter_value(field, value, valid_values) do - if MapSet.member?(valid_values, value) do - :ok - else - {:error, field, inspect(value)} - end - end - - defp filter_validation_notice(field, invalid_value) do - %{ - error_type: @filter_validation_error_type, - detail: "Invalid #{field} value #{invalid_value} was submitted for workbench filters; defaults were restored.", - remediation: "Select values from the listed project, state, and freshness options and retry." - } - end - - defp filter_restore_validation_notice(field, invalid_value) do - %{ - error_type: @filter_restore_validation_error_type, - detail: - "Workbench state restoration failed because #{field} value #{invalid_value} is invalid; defaults were restored.", - remediation: "Use workbench filters to reselect project, state, freshness, and sort preferences." - } - end - - defp workbench_path_with_filter_values(filter_values) do - ManagedRepoRoutes.workbench_path(filter_values) - end - - defp filter_chips(filter_values, rows) do - project_label = - case Map.fetch!(filter_values, "project_id") do - @default_project_filter_value -> - "All repositories" - - project_id -> - rows - |> Enum.find(fn row -> Map.get(row, :id) == project_id end) - |> case do - nil -> "All repositories" - row -> row |> Map.get(:github_full_name) |> normalize_optional_string() || project_id - end - end - - %{ - project: project_label, - work_state: - option_label( - @work_state_filter_options, - Map.fetch!(filter_values, "work_state"), - "Any issue or PR state" - ), - freshness_window: - option_label( - @freshness_filter_options, - Map.fetch!(filter_values, "freshness_window"), - "Any freshness" - ), - sort_order: - option_label( - @sort_order_options, - Map.fetch!(filter_values, "sort_order"), - "Repository name (A-Z)" - ) - } - end - - defp sort_rows_with_fallback(rows, filter_values) do - requested_sort_order = Map.fetch!(filter_values, "sort_order") - - case sort_rows(rows, requested_sort_order) do - {:ok, sorted_rows} -> - {sorted_rows, filter_values, nil} - - {:error, reason} -> - fallback_sort_order = @default_sort_order_value - {:ok, fallback_rows} = sort_rows(rows, fallback_sort_order) - - { - fallback_rows, - Map.put(filter_values, "sort_order", fallback_sort_order), - sort_validation_notice(requested_sort_order, fallback_sort_order, reason) - } - end - end - - defp sort_rows(rows, @default_sort_order_value) do - {:ok, Enum.sort_by(rows, &project_sort_key/1)} - end - - defp sort_rows(rows, "backlog_desc") do - with :ok <- validate_rows_for_sort(rows, :backlog) do - sorted_rows = - Enum.sort_by(rows, fn row -> - {-backlog_size(row), project_sort_key(row)} - end) - - {:ok, sorted_rows} - end - end - - defp sort_rows(rows, "backlog_asc") do - with :ok <- validate_rows_for_sort(rows, :backlog) do - sorted_rows = - Enum.sort_by(rows, fn row -> - {backlog_size(row), project_sort_key(row)} - end) - - {:ok, sorted_rows} - end - end - - defp sort_rows(rows, "recent_activity_desc") do - with :ok <- validate_rows_for_sort(rows, :recent_activity) do - sorted_rows = - Enum.sort_by(rows, fn row -> - recent_activity_sort_key(row, :desc) - end) - - {:ok, sorted_rows} - end - end - - defp sort_rows(rows, "recent_activity_asc") do - with :ok <- validate_rows_for_sort(rows, :recent_activity) do - sorted_rows = - Enum.sort_by(rows, fn row -> - recent_activity_sort_key(row, :asc) - end) - - {:ok, sorted_rows} - end - end - - defp sort_rows(_rows, _sort_order), do: {:error, :unknown_sort_order} - - defp validate_rows_for_sort(rows, :backlog) do - if Enum.any?(rows, &malformed_row_for_backlog_sort?/1) do - {:error, :malformed_backlog_data} - else - :ok - end - end - - defp validate_rows_for_sort(rows, :recent_activity) do - if Enum.any?(rows, &malformed_row_for_recent_activity_sort?/1) do - {:error, :malformed_recent_activity_data} - else - :ok - end - end - - defp backlog_size(row) do - Map.get(row, :open_issue_count, 0) + Map.get(row, :open_pr_count, 0) - end - - defp recent_activity_sort_key(row, direction) do - activity_at = row_recent_activity_at(row) - missing_activity = is_nil(activity_at) - activity_unix = if activity_at, do: DateTime.to_unix(activity_at, :microsecond), else: 0 - - case direction do - :desc -> - {if(missing_activity, do: 1, else: 0), -activity_unix, project_sort_key(row)} - - :asc -> - {if(missing_activity, do: 1, else: 0), activity_unix, project_sort_key(row)} - end - end - - defp malformed_row_for_backlog_sort?(row) do - fallback_row_id?(row) or - not non_negative_integer?(Map.get(row, :open_issue_count)) or - not non_negative_integer?(Map.get(row, :open_pr_count)) - end - - defp malformed_row_for_recent_activity_sort?(row) do - fallback_row_id?(row) or - case row_recent_activity_at(row) do - %DateTime{} -> false - nil -> false - _other -> true - end - end - - defp fallback_row_id?(row) do - case row |> Map.get(:id) |> normalize_optional_string() do - <<@fallback_row_id_prefix, _::binary>> -> true - _other -> false - end - end - - defp non_negative_integer?(value), do: is_integer(value) and value >= 0 - - defp project_sort_key(row) do - { - row |> Map.get(:github_full_name) |> sort_string_key(), - row |> Map.get(:name) |> sort_string_key(), - row |> Map.get(:id) |> sort_string_key() - } - end - - defp sort_string_key(value) do - value - |> normalize_optional_string() - |> case do - nil -> "" - normalized -> String.downcase(normalized) - end - end - - defp sort_validation_notice(requested_sort_order, fallback_sort_order, reason) do - requested_sort_label = - option_label(@sort_order_options, requested_sort_order, requested_sort_order) - - fallback_sort_label = - option_label(@sort_order_options, fallback_sort_order, fallback_sort_order) - - %{ - error_type: @sort_validation_error_type, - detail: - "Sort order #{requested_sort_label} could not be applied (#{sort_failure_reason(reason)}). #{fallback_sort_label} was applied instead.", - remediation: "Refresh workbench data and verify backlog and activity metadata before retrying." - } - end - - defp sort_failure_reason(:malformed_backlog_data), do: "malformed backlog fields were detected" - - defp sort_failure_reason(:malformed_recent_activity_data), - do: "malformed activity fields were detected" - - defp sort_failure_reason(:unknown_sort_order), do: "unsupported sort value was submitted" - defp sort_failure_reason(_reason), do: "sorting metadata validation failed" - - defp option_label(options, value, default_label) do - Enum.find_value(options, default_label, fn - {label, ^value} -> label - _other -> nil - end) - end - - defp project_filter_options(rows) do - dynamic_options = - rows - |> Enum.map(fn row -> - label = - row - |> Map.get(:github_full_name) - |> normalize_optional_string() || - row - |> Map.get(:name) - |> normalize_optional_string() || - Map.get(row, :id) - - {label, Map.get(row, :id)} - end) - |> Enum.reject(fn {label, value} -> is_nil(label) or is_nil(value) end) - |> Enum.uniq_by(fn {_label, value} -> value end) - |> Enum.sort_by(fn {label, _value} -> String.downcase(label) end) - - [{"All repositories", @default_project_filter_value} | dynamic_options] - end - - defp work_state_filter_options, do: @work_state_filter_options - defp freshness_filter_options, do: @freshness_filter_options - defp sort_order_options, do: @sort_order_options - defp to_filter_form(filter_values), do: to_form(filter_values, as: :filters) - - defp empty_state_message(0), do: "No imported repositories available yet." - defp empty_state_message(_inventory_total_count), do: "No repositories match the active filters." - - defp map_get(map, string_key, atom_key) do - cond do - Map.has_key?(map, string_key) -> Map.get(map, string_key) - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - true -> nil - end - end - - defp find_project_row(rows, project_id) when is_list(rows) do - normalized_project_id = normalize_optional_string(project_id) - - Enum.find(rows, fn row -> - row - |> map_get("id", :id) - |> normalize_optional_string() == normalized_project_id - end) - end - - defp find_project_row(_rows, _project_id), do: nil - - defp put_fix_workflow_kickoff_state( - socket, - project_id, - context_item_type, - kickoff_result, - return_to - ) do - update(socket, :fix_workflow_kickoff_states, fn states -> - InventoryActionState.put_fix_feedback( - states, - project_id, - context_item_type, - kickoff_result, - return_to - ) - end) - end - - defp put_issue_triage_workflow_kickoff_state( - socket, - project_id, - context_item_type, - kickoff_result, - return_to - ) do - update(socket, :issue_triage_workflow_kickoff_states, fn states -> - InventoryActionState.put_issue_triage_feedback( - states, - project_id, - context_item_type, - kickoff_result, - return_to - ) - end) - end - - defp put_recent_run_outcome_from_kickoff(socket, project_id, kickoff_result, return_to) do - update(socket, :recent_run_outcomes, fn outcomes -> - InventoryActionState.put_recent_run_outcome(outcomes, project_id, kickoff_result, return_to) - end) - end - - defp current_workbench_path(filter_values) do - filter_values - |> normalize_filter_values() - |> workbench_path_with_filter_values() - end - - defp refresh_project_row(socket, project_row) when is_map(project_row) do - stream_insert(socket, :inventory_rows, project_row) - end - - defp refresh_project_row(socket, _project_row), do: socket - - defp recent_run_outcome(outcomes, project_id) when is_map(outcomes) do - normalized_project_id = normalize_optional_string(project_id) - - if normalized_project_id do - Map.get(outcomes, normalized_project_id) - end - end - - defp recent_run_outcome(_outcomes, _project_id), do: nil - - defp project_detail_path(project, filter_values) do - return_to = workbench_path_with_filter_values(normalize_filter_values(filter_values)) - InventorySurface.project_detail_path(project, return_to) - end - - defp initiating_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - %{ - id: - user - |> Map.get(:id) - |> normalize_optional_string() || "unknown", - email: - user - |> Map.get(:email) - |> normalize_optional_string() - } - - _other -> - %{id: "unknown", email: nil} - end - end - - defp normalize_optional_string(nil), do: nil - defp normalize_optional_string(value) when is_boolean(value), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized_value -> normalized_value - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_optional_string(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_optional_string(_value), do: nil - - defp normalize_optional_datetime(%DateTime{} = datetime), do: datetime - - defp normalize_optional_datetime(%NaiveDateTime{} = datetime) do - case DateTime.from_naive(datetime, "Etc/UTC") do - {:ok, parsed_datetime} -> parsed_datetime - _other -> nil - end - end - - defp normalize_optional_datetime(value) when is_binary(value) do - case DateTime.from_iso8601(value) do - {:ok, parsed_datetime, _offset} -> - parsed_datetime - - _other -> - case NaiveDateTime.from_iso8601(value) do - {:ok, parsed_naive_datetime} -> - normalize_optional_datetime(parsed_naive_datetime) - - _fallback -> - nil - end - end - end - - defp normalize_optional_datetime(_value), do: nil + use JidoCodeWeb.ResetLive, title: "Workbench" end diff --git a/lib/jido_code_web/live/workflows_live.ex b/lib/jido_code_web/live/workflows_live.ex index d2850fd2..95a9a8ed 100644 --- a/lib/jido_code_web/live/workflows_live.ex +++ b/lib/jido_code_web/live/workflows_live.ex @@ -1,517 +1,3 @@ defmodule JidoCodeWeb.WorkflowsLive do - use JidoCodeWeb, :live_view - - alias JidoCode.WorkflowRuntime.ManualRunKickoff - alias JidoCodeWeb.OperatorShell - - @impl true - def mount(_params, _session, socket) do - workflows = ManualRunKickoff.supported_workflows() - repositories = ManualRunKickoff.repository_options() - run_form_values = default_run_form_values(workflows, repositories) - - {:ok, - socket - |> assign(:workflows, workflows) - |> assign(:repository_count, length(repositories)) - |> assign(:repository_options, repository_select_options(repositories)) - |> assign(:run_feedback, nil) - |> assign(:run_count, 0) - |> assign(:run_form_values, run_form_values) - |> assign(:run_form, to_form(run_form_values, as: :run)) - |> stream_configure(:runs, dom_id: &run_dom_id/1) - |> stream(:runs, [], reset: true)} - end - - @impl true - def handle_event("change_run_form", %{"run" => run_params}, socket) do - run_form_values = normalize_run_form_values(run_params, socket.assigns.run_form_values) - - {:noreply, - socket - |> assign(:run_feedback, nil) - |> assign(:run_form_values, run_form_values) - |> assign(:run_form, to_form(run_form_values, as: :run))} - end - - @impl true - def handle_event("change_run_form", _params, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("start_workflow", %{"run" => run_params}, socket) do - run_form_values = normalize_run_form_values(run_params, socket.assigns.run_form_values) - - case ManualRunKickoff.kickoff(run_form_values, initiating_actor(socket)) do - {:ok, kickoff_run} -> - refreshed_form_values = - clear_required_inputs( - run_form_values, - kickoff_run.workflow_name, - socket.assigns.workflows - ) - - {:noreply, - socket - |> assign(:run_feedback, %{status: :ok, run: kickoff_run}) - |> assign(:run_form_values, refreshed_form_values) - |> assign(:run_form, to_form(refreshed_form_values, as: :run)) - |> stream_insert(:runs, kickoff_run, at: 0) - |> update(:run_count, &(&1 + 1))} - - {:error, kickoff_error} -> - {:noreply, - socket - |> assign(:run_feedback, %{status: :error, error: kickoff_error}) - |> assign(:run_form_values, run_form_values) - |> assign(:run_form, to_form(run_form_values, as: :run))} - end - end - - @impl true - def handle_event("start_workflow", _params, socket) do - {:noreply, socket} - end - - @impl true - def render(assigns) do - ~H""" - - <.single_pane_shell id="workflows-shell" breadcrumbs={workflows_breadcrumbs()} pane={workflows_pane()}> -
- <.form - for={@run_form} - id="workflows-manual-run-form" - phx-change="change_run_form" - phx-submit="start_workflow" - class="space-y-3" - > -
- <.input - id="workflows-project-id" - field={@run_form[:repo_id]} - type="select" - label="Repo scope" - options={@repository_options} - /> - <.input - id="workflows-workflow-name" - field={@run_form[:workflow_name]} - type="select" - label="Workflow template" - options={workflow_select_options(@workflows)} - /> -
- - <% selected_workflow = selected_workflow(@workflows, @run_form_values) %> - <%= if selected_workflow do %> -
-

- Required inputs for {selected_workflow.label} -

-

- {selected_workflow.description} -

- - <.input - :for={input <- selected_workflow.required_inputs} - id={"workflows-input-#{workflow_input_dom_id(input.name)}"} - field={workflow_input_form_field(@run_form, input.name)} - type="textarea" - label={input.label} - placeholder={input.placeholder} - /> -
- <% end %> - -

- Import at least one repository before starting manual workflow runs. -

- - -
- <%= case @run_feedback.status do %> - <% :ok -> %> -

- Run creation succeeded. -

-

- Run: {@run_feedback.run.run_id} -

-

- Workflow version: {run_workflow_version_summary(@run_feedback.run.workflow_version)} -

- <.link - id="workflows-run-feedback-run-link" - class="link link-primary text-sm" - href={@run_feedback.run.detail_path} - > - Open run detail - - <% :error -> %> -

- Run creation failed. -

-

- Typed validation error: {@run_feedback.error.error_type} -

-

- {@run_feedback.error.detail} -

-

- {@run_feedback.error.remediation} -

-
    -
  • - {field_error.field}: {field_error.detail} -
  • -
- <% end %> -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Run IDWorkflowVersionRepositoryTriggerInputsRoute
- No workflow runs started from this page yet. -
- {run.run_id} - - {run.workflow_name} - - {run_workflow_version_summary(run.workflow_version)} - - {run.repository_name} - - {run_trigger_summary(run.trigger)} - - {run_input_summary(run.inputs)} - - <.link - id={"workflows-run-detail-link-#{run_dom_token(run.run_id)}"} - class="link link-primary" - href={run.detail_path} - > - Open run detail - -
-
- - <:footer_actions> - <%= if @repository_count > 0 do %> - - <% else %> - - Start workflow run - - <% end %> - - -
- """ - end - - defp workflows_breadcrumbs do - [ - OperatorShell.breadcrumb(%{ - id: "workflows-breadcrumb-current", - label: "Workflows", - current?: true - }) - ] - end - - defp workflows_pane do - OperatorShell.pane(%{ - id: "workflows-pane", - title: "Governed workflow kickoff", - summary: "Start manual workflow runs with explicit repo scope and required input metadata." - }) - end - - defp default_run_form_values(workflows, repositories) do - %{ - "repo_id" => - repositories - |> List.first() - |> map_get(:id, "id", ""), - "workflow_name" => - workflows - |> List.first() - |> map_get(:name, "name", ""), - "task_summary" => "", - "failure_signal" => "", - "issue_reference" => "" - } - end - - defp normalize_run_form_values(run_params, current_values) when is_map(run_params) do - %{ - "repo_id" => form_value(run_params, current_values, :repo_id, "repo_id"), - "workflow_name" => form_value(run_params, current_values, :workflow_name, "workflow_name"), - "task_summary" => form_value(run_params, current_values, :task_summary, "task_summary"), - "failure_signal" => form_value(run_params, current_values, :failure_signal, "failure_signal"), - "issue_reference" => form_value(run_params, current_values, :issue_reference, "issue_reference") - } - end - - defp normalize_run_form_values(_run_params, current_values), do: current_values - - defp form_value(run_params, current_values, atom_key, string_key) do - if Map.has_key?(run_params, atom_key) or Map.has_key?(run_params, string_key) do - run_params - |> map_get(atom_key, string_key, "") - |> normalize_form_value() - else - Map.get(current_values, string_key, "") - end - end - - defp clear_required_inputs(run_form_values, workflow_name, workflows) do - required_input_keys = - workflows - |> Enum.find(fn workflow -> - Map.get(workflow, :name) == workflow_name - end) - |> case do - nil -> - [] - - workflow -> - workflow - |> Map.get(:required_inputs, []) - |> Enum.map(fn input -> Atom.to_string(Map.fetch!(input, :name)) end) - end - - Enum.reduce(required_input_keys, run_form_values, fn key, acc -> - Map.put(acc, key, "") - end) - end - - defp repository_select_options([]), do: [{"Select a repository", ""}] - - defp repository_select_options(repositories) do - Enum.map(repositories, fn repository -> - label = Map.get(repository, :github_full_name) || Map.get(repository, :name) || Map.get(repository, :id) - {label, Map.get(repository, :id)} - end) - end - - defp workflow_select_options(workflows) do - Enum.map(workflows, fn workflow -> - {workflow.label, workflow.name} - end) - end - - defp selected_workflow(workflows, run_form_values) when is_list(workflows) and is_map(run_form_values) do - selected_workflow_name = - run_form_values - |> map_get(:workflow_name, "workflow_name") - |> normalize_optional_string() - - Enum.find(workflows, fn workflow -> - workflow.name == selected_workflow_name - end) || List.first(workflows) - end - - defp selected_workflow(_workflows, _run_form_values), do: nil - - defp workflow_input_form_field(run_form, :task_summary), do: run_form[:task_summary] - defp workflow_input_form_field(run_form, :failure_signal), do: run_form[:failure_signal] - defp workflow_input_form_field(run_form, :issue_reference), do: run_form[:issue_reference] - - defp workflow_input_form_field(run_form, input_name) do - run_form[normalize_input_key(input_name)] - end - - defp normalize_input_key(:task_summary), do: :task_summary - defp normalize_input_key(:failure_signal), do: :failure_signal - defp normalize_input_key(:issue_reference), do: :issue_reference - defp normalize_input_key(_input_name), do: :task_summary - - defp workflow_input_dom_id(input_name) do - input_name - |> normalize_input_key() - |> Atom.to_string() - |> String.replace("_", "-") - end - - defp run_dom_id(run) do - "workflow-run-#{run |> Map.get(:run_id) |> run_dom_token()}" - end - - defp run_dom_token(value) do - value - |> normalize_optional_string() - |> case do - nil -> "unknown" - token -> token - end - |> String.replace(~r/[^a-zA-Z0-9_-]/, "-") - end - - defp run_trigger_summary(trigger) when is_map(trigger) do - source = trigger |> map_get(:source, "source") |> normalize_optional_string() || "unknown" - mode = trigger |> map_get(:mode, "mode") |> normalize_optional_string() || "unknown" - - source_row = - trigger - |> map_get(:source_row, "source_row", %{}) - |> map_get(:route, "route") - |> normalize_optional_string() || "/workflows" - - "#{mode} via #{source} (#{source_row})" - end - - defp run_trigger_summary(_trigger), do: "manual via workflows (/workflows)" - - defp run_input_summary(inputs) when is_map(inputs) do - inputs - |> Enum.sort_by(fn {name, _value} -> name end) - |> Enum.map(fn {name, value} -> - "#{name}=#{summary_value(value)}" - end) - |> Enum.join("; ") - end - - defp run_input_summary(_inputs), do: "no inputs" - - defp run_workflow_version_summary(version) when is_integer(version) and version > 0, - do: "v#{version}" - - defp run_workflow_version_summary(_version), do: "unknown" - - defp summary_value(value) do - value - |> normalize_optional_string() - |> case do - nil -> - "n/a" - - normalized_value when byte_size(normalized_value) > 72 -> - normalized_value - |> binary_part(0, 72) - |> Kernel.<>("...") - - normalized_value -> - normalized_value - end - end - - defp initiating_actor(socket) do - socket.assigns - |> Map.get(:current_user) - |> case do - %{} = user -> - %{ - id: - user - |> Map.get(:id) - |> normalize_optional_string() || "unknown", - email: - user - |> Map.get(:email) - |> normalize_optional_string() - } - - _other -> - %{id: "unknown", email: nil} - end - end - - defp map_get(map, atom_key, string_key, default \\ nil) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default - - defp normalize_form_value(value) when is_binary(value), do: String.trim(value) - - defp normalize_form_value(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_form_value() - - defp normalize_form_value(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_form_value(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_form_value(_value), do: "" - - defp normalize_optional_string(nil), do: nil - defp normalize_optional_string(value) when is_boolean(value), do: nil - - defp normalize_optional_string(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - normalized_value -> normalized_value - end - end - - defp normalize_optional_string(value) when is_atom(value), - do: value |> Atom.to_string() |> normalize_optional_string() - - defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) - defp normalize_optional_string(value) when is_float(value), do: :erlang.float_to_binary(value) - defp normalize_optional_string(_value), do: nil + use JidoCodeWeb.ResetLive, title: "Workflows" end diff --git a/lib/jido_code_web/reset_live.ex b/lib/jido_code_web/reset_live.ex new file mode 100644 index 00000000..f1919b8a --- /dev/null +++ b/lib/jido_code_web/reset_live.ex @@ -0,0 +1,49 @@ +defmodule JidoCodeWeb.ResetLive do + @moduledoc false + + use Phoenix.Component + + defmacro __using__(opts) do + title = Keyword.fetch!(opts, :title) + + quote bind_quoted: [title: title] do + use JidoCodeWeb, :live_view + + @reset_page_title title + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, page_title: @reset_page_title, route_uri: nil)} + end + + @impl true + def handle_params(_params, uri, socket) do + {:noreply, assign(socket, :route_uri, uri)} + end + + @impl true + def render(assigns) do + JidoCodeWeb.ResetLive.render(assigns) + end + end + end + + def render(assigns) do + ~H""" +
+
+

+ Jido.Code +

+

+ {@page_title} +

+

+ This route is available for the new frontend rebuild. The previous UI component + and app layout layer has been removed. +

+
+
+ """ + end +end diff --git a/lib/jido_code_web/router.ex b/lib/jido_code_web/router.ex index 91a870ba..7fd613a8 100644 --- a/lib/jido_code_web/router.ex +++ b/lib/jido_code_web/router.ex @@ -96,31 +96,25 @@ defmodule JidoCodeWeb.Router do reset_path: "/reset", auth_routes_prefix: "/auth", on_mount: [{JidoCodeWeb.LiveUserAuth, :live_no_user}], - overrides: [ - JidoCodeWeb.AuthOverrides, - Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI - ] + overrides: [JidoCodeWeb.AuthOverrides] ) # Remove this if you do not want to use the reset password feature reset_route( auth_routes_prefix: "/auth", - overrides: [ - JidoCodeWeb.AuthOverrides, - Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI - ] + overrides: [JidoCodeWeb.AuthOverrides] ) # Remove this if you do not use the confirmation strategy confirm_route(JidoCode.Accounts.User, :confirm_new_user, auth_routes_prefix: "/auth", - overrides: [JidoCodeWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI] + overrides: [JidoCodeWeb.AuthOverrides] ) # Remove this if you do not use the magic link strategy. magic_sign_in_route(JidoCode.Accounts.User, :magic_link, auth_routes_prefix: "/auth", - overrides: [JidoCodeWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI] + overrides: [JidoCodeWeb.AuthOverrides] ) end diff --git a/mix.exs b/mix.exs index 4112baff..049b9c32 100644 --- a/mix.exs +++ b/mix.exs @@ -141,6 +141,7 @@ defmodule JidoCode.MixProject do {:phoenix_html, "~> 4.1"}, {:phoenix_live_view, "~> 1.1"}, {:phoenix_live_dashboard, "~> 0.8"}, + {:salad_ui, "~> 1.0.0-beta.3"}, {:bandit, "~> 1.5"}, # Ash framework and extensions @@ -210,14 +211,13 @@ defmodule JidoCode.MixProject do # Utilities {:dotenvy, "~> 1.1"}, - {:live_toast, "~> 0.8"}, {:jason, "~> 1.2"}, {:picosat_elixir, "~> 0.2"}, {:mdex, "~> 0.12"}, {:zoi, "~> 0.17"}, # Development & testing - {:sourceror, "~> 1.8", only: [:dev, :test]}, + {:sourceror, "~> 1.8"}, {:lazy_html, ">= 0.1.0"}, {:tidewave, "~> 0.5.6", only: [:dev]}, # TODO: re-enable once startup perf is fixed (v0.6.0 adds ~28s to boot) diff --git a/mix.lock b/mix.lock index 1a823f58..8e0d1fce 100644 --- a/mix.lock +++ b/mix.lock @@ -44,11 +44,9 @@ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_ontologies": {:git, "https://github.com/pcharbon70/elixir-ontologies.git", "ccb069136f3b96cfd3684468a8ec1be70f41dc0b", [ref: "ccb069136f3b96cfd3684468a8ec1be70f41dc0b"]}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, - "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_aws_auth": {:hex, :ex_aws_auth, "1.3.1", "3963992d6f7cb251b53573603c3615cec70c3f4d86199fdb865ff440295ef7a4", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: true]}], "hexpm", "025793aa08fa419aabdb652db60edbdb2e12346bd447988a1bb5854c4dd64903"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, "ex_cmd": {:hex, :ex_cmd, "0.18.0", "791b42b864c099b67254ca909243e2f60a6e091c802f608d4ae2e3da27b0ade9", [:mix], [], "hexpm", "fd4ec30607a7c789cd53b5fb09189bd0a4922eae5b4fadf70176c19bcd19fb76"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, @@ -64,12 +62,13 @@ "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_hooks": {:hex, :git_hooks, "0.8.1", "1f6a1b065638e07ed89a49804dac6c24d8ac8d27c8f9fd0e9620d5bef8c30f41", [:mix], [{:recase, "~> 0.8.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "267d8b82615ad439177b2a4bc2efadb7491ec1c8520dacc67ddc38c251448cbc"}, "git_ops": {:hex, :git_ops, "2.9.3", "daede6f856965ab8144fb729ee114999d0ae8f49a55361adc7da1cff5c5c6b0a", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "952df74d2f6c1877779c0644a8f102119d36ecf2a52bb2fd0503b0416db61978"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jcs": {:hex, :jcs, "0.2.0", "e0524c23b576e8247f9f5f09d1b82cb3f92c7b132932b82d5d656461831c6c99", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f52e86571f56fab695682bf0ab7fd697768acb20de16b30809e9654d1ec1c9dd"}, @@ -77,14 +76,8 @@ "jido_action": {:hex, :jido_action, "2.2.0", "a1f3e8397e445d8b05f3e6657be2b28ef23e7877e58841a1dc1f0886d560aab8", [:mix], [{:abacus, "~> 2.1", [hex: :abacus, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16.1-mg.1", [hex: :multigraph, repo: "hexpm", optional: false]}, {:lua, "~> 0.3", [hex: :lua, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:private, "~> 0.1.2", [hex: :private, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10", [hex: :req, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.1", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6.1", [hex: :uniq, repo: "hexpm", optional: false]}, {:zoi, "~> 0.17", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "d4a1b37fad1e252555684aec60bc36b884ece4d7c5169e7a3682c6918b3ab4ab"}, "jido_agent_os": {:git, "https://github.com/pcharbon70/jido_agent_os.git", "37e59c6e56ca5e303ed5b03a31bff1a28eff9eef", [branch: "main"]}, "jido_ai": {:git, "https://github.com/agentjido/jido_ai.git", "5601aa5eec5341a3cd62c951c029e53cd584a110", [branch: "main"]}, - "jido_command": {:git, "https://github.com/pcharbon70/jido_command.git", "59017df52ad98fe43e0924a225cf1675e5c6c475", [branch: "main"]}, - "jido_conversation": {:git, "https://github.com/pcharbon70/jido_conversation.git", "260bd0c1e9d3352818b03fcc20dd88e6bd5c9aa2", [branch: "main"]}, "jido_memory": {:git, "https://github.com/agentjido/jido_memory.git", "8152bd49c7482d74c06d3320b5310352a220b071", [branch: "main"]}, - "jido_shell": {:git, "https://github.com/agentjido/jido_shell.git", "7a99ce9c1b32f305628fb0238dcf2de2fd2e89d7", [branch: "main"]}, "jido_signal": {:hex, :jido_signal, "2.1.1", "7f9e96984f0ac33c3eb7901fdd9e4abd1296b12bfa282977959103e633be9e86", [:mix], [{:fuse, "~> 2.5", [hex: :fuse, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:memento, "~> 0.5.0", [hex: :memento, repo: "hexpm", optional: false]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6.1", [hex: :uniq, repo: "hexpm", optional: false]}, {:zoi, "~> 0.17.1", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "45744ffac00a734a63fddceafb6d447a9e36ae5918adcd467a68f188a29b4320"}, - "jido_skill": {:git, "https://github.com/pcharbon70/jido_skill.git", "cc5ec5aaf5ae1c362952cb71949e759e570ddb82", [branch: "main"]}, - "jido_vfs": {:git, "https://github.com/agentjido/jido_vfs.git", "0817e6cade2e34dacf6b2e648e86ea14f4a84c84", [branch: "main"]}, - "jido_workspace": {:git, "https://github.com/agentjido/jido_workspace.git", "d01bf67e4911fad06a379b41b8e9f74966d73310", [branch: "main"]}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "json_xema": {:hex, :json_xema, "0.6.5", "060459c9c9152650edb4427b1acbc61fa43a23bcea0301d200cafa76e0880f37", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.16", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "b8ffdbc2f67aa8b91b44e1ba0ab77eb5c0b0142116f8fbb804977fb939d470ef"}, @@ -92,7 +85,6 @@ "jsv": {:hex, :jsv, "0.17.1", "bee75ee07df9bce75deb957e0e2dbe7924874a8aa93a529054656fc0a78adff0", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.0 or ~> 7.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:poison, ">= 3.0.0 and < 7.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:texture, "~> 0.3", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "3d66b84473d2df6445b896b03872293106786574204e15bfe5bec4143e912958"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, "libgraph": {:git, "https://github.com/zblanco/libgraph.git", "416f99e002cebe82f5640014bc2c79dcf318dcd1", [branch: "zw/multigraph-indexes"]}, - "live_toast": {:hex, :live_toast, "0.8.0", "d7e24801a9a966d8e48872767ac33c50fc14640ac5272128becfc534fabd99b7", [:mix], [{:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:gettext, ">= 0.26.2", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.19", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.0.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7e64435131b7731698908d59f7ba9f5092df8a3720814f26edf57e99d97a803c"}, "live_vue": {:hex, :live_vue, "1.0.1", "f2e446bfeac888f55888d8ca30eb37a0fea8a797719875b1e81315a1bbce6d48", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:jsonpatch, "~> 2.3", [hex: :jsonpatch, repo: "hexpm", optional: false]}, {:lazy_html, ">= 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_vite, "~> 0.4", [hex: :phoenix_vite, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "321b812d27cbe6086abac1db5d6e666b133b09f9ad23fe6aabf803e30f12cf6f"}, "llm_db": {:hex, :llm_db, "2026.3.3", "fa8eb363c65f5c0bf838207157a4168aad332446d01ae8e63e43c44780a61381", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "456306182a329220d85d6a33ea96d8d6e0a353f21d0f82b12debcc2c136b6397"}, "lua": {:hex, :lua, "0.4.0", "de0f04871fdd133cd13a0662690b4fd3ba7a73ca5857493c4665a0a4251908fe", [:mix], [{:luerl, "~> 1.5.1", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "648e17ab9faa1ab1a788fa58ed608366a7026d0eeaec2f311510c065817c4067"}, @@ -115,7 +107,7 @@ "nodejs": {:hex, :nodejs, "3.1.4", "c165a0e5901966e98d7965a20375a81d84ea89cadcde6c513f7f466e7166b3d3", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "ce69d966a743bdc002892358f653bf4c2074a6e8a4a46f99299d8fd8174d0195"}, "ok": {:hex, :ok, "2.3.0", "0a3d513ec9038504dc5359d44e14fc14ef59179e625563a1a144199cdc3a6d30", [:mix], [], "hexpm", "f0347b3f8f115bf347c704184b33cf084f2943771273f2b98a3707a5fa43c4d5"}, "open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"}, - "optimus": {:hex, :optimus, "0.6.1", "b493bdc7ee71035fdf8cbfbf158b1863e2d622b65c3906153e4042815c901fca", [:mix], [], "hexpm", "c0db4107a51f5af94de8b05e4208333ebb8016a3bfdbcd74df6e5c99829db17f"}, + "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, @@ -141,14 +133,17 @@ "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "req_llm": {:hex, :req_llm, "1.9.0", "1a7dfd5ee5cd94f3e37a499c5a9a18733f37ede46c0e3f54bb644ae45048f0f8", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.3", [hex: :ex_aws_auth, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, "~> 2026.3.3", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 0.2", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "266d893ad537b066b84db85640ecc446821f38c6ddba77632455044bc722b682"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rocksdb": {:hex, :rocksdb, "2.6.2", "d73f0653596b458d13b97cccfa2f89e6e23f5c8c4e23c1d84b81826c33e24ec4", [:rebar3], [], "hexpm", "b4e440bc9eb4c10add69fb0093e55f025e06a0e4677fa6412ffb72889a9fc8fa"}, "rustler": {:hex, :rustler, "0.37.3", "5f4e6634d43b26f0a69834dd1d3ed4e1710b022a053bf4a670220c9540c92602", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a6872c6f53dcf00486d1e7f9e046e20e01bf1654bdacc4193016c2e8002b32a2"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "salad_ui": {:hex, :salad_ui, "1.0.0-beta.3", "614ff717ca09d004df847e83d9a3a221179fc6fc577945cf3b2c3bd997a1d017", [:mix], [{:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.9", [hex: :sourceror, repo: "hexpm", optional: false]}, {:tw_merge, "~> 0.1", [hex: :tw_merge, repo: "hexpm", optional: false]}], "hexpm", "4def64f24429c14bce0e98d755937801dd7677b381aa5453ca8f74b322e48108"}, "server_sent_events": {:hex, :server_sent_events, "0.2.1", "f83b34f01241302a8bf451efc8dde3a36c533d5715463c31c653f3db8695f636", [:mix], [], "hexpm", "c8099ce4f9acd610eb7c8e0f89dba7d5d1c13300ea9884b0bd8662401d3cf96f"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.6.1", "b0100216d3883c6a281cb2434af45afbd808695aadb034923cbaf7d8a2ba46ab", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "77bbefa5263bb6b70e1195bc0fc662ddb8ef5937a356a77ae072e56983ad13f0"}, "sparql": {:hex, :sparql, "0.3.11", "9714a561c16652cbdccb5baafc0d0332cdb4ba0c67abf9d8e23bc3c58e0ce965", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:protocol_ex, "~> 0.4", [hex: :protocol_ex, repo: "hexpm", optional: false]}, {:rdf, "~> 2.1", [hex: :rdf, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "7824e163bb2452ba8cc0335b2a43580fd568e5ce3a3e620a626277c19f40015f"}, + "spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "sprites": {:git, "https://github.com/mikehostetler/sprites-ex.git", "07b225e8c1eeb35d1bfc9690e1f2fda5165b2a99", []}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, @@ -158,12 +153,13 @@ "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, - "tentacat": {:hex, :tentacat, "2.5.0", "d0177ae1289faf6814a85aea044bcdc1ca64a4b1f961e44189451d5c9060a662", [:mix], [{:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c7d3d34a56d5dc870c2155444f7d6cd0e6959dd65c16bb9174442e347f34334f"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "texture": {:hex, :texture, "0.3.2", "ca68fc2804ce05ffe33cded85d69b5ebadb0828233227accfe3c574e34fd4e3f", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}], "hexpm", "43bb1069d9cf4309ed6f0ff65ade787a76f986b821ab29d1c96b5b5102cb769c"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "triple_store": {:git, "https://github.com/pcharbon70/triple_store.git", "ff3665c4f8e6bbb0e68f966d88d5d9e952e4de2a", [ref: "ff3665c4f8e6bbb0e68f966d88d5d9e952e4de2a"]}, + "tw_merge": {:hex, :tw_merge, "0.1.1", "88127783b0039bd85cdc0d8d7f4498f794c4fe30c9c0b35fdc8dcc1f0f18b4dc", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "443f683fe21e171ac1fe1a5105b8b5862d85e0385595c983e8e09d053129e04f"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, diff --git a/package-lock.json b/package-lock.json index 24abc20a..dd9438e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,23 +5,31 @@ "packages": { "": { "dependencies": { - "@vueuse/core": "^13.7.0", + "@lucide/vue": "^1.21.0", + "@radix-icons/vue": "^1.0.0", + "@vueuse/core": "^14.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "live_vue": "file:./deps/live_vue", "phoenix": "file:./deps/phoenix", "phoenix_html": "file:./deps/phoenix_html", "phoenix_live_view": "file:./deps/phoenix_live_view", + "reka-ui": "^2.10.0", + "salad_ui": "file:deps/salad_ui", + "tailwind-merge": "^3.6.0", "topbar": "^3.0.0", - "vue": "^3.4.21" + "tw-animate-css": "^1.4.0", + "vue": "^3.5.21" }, "devDependencies": { "@playwright/test": "^1.54.2", - "@tailwindcss/vite": "^4.1.0", - "@vitejs/plugin-vue": "^5.0.4", - "daisyui": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@vitejs/plugin-vue": "^6.0.7", "phoenix_vite": "file:./deps/phoenix_vite", - "tailwindcss": "^4.1.0", + "tailwindcss": "^4.1.7", "typescript": "^5.4.5", - "vite": "^6.3.0", + "vite": "^7.3.5", + "vitest": "^3.2.6", "vue-tsc": "^2.0.13" } }, @@ -53,6 +61,20 @@ "deps/live_vue/deps/phoenix_live_view": { "dev": true }, + "deps/live_vue/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, "deps/live_vue/node_modules/phoenix": { "resolved": "deps/live_vue/deps/phoenix", "link": true @@ -319,713 +341,154 @@ "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "deps/phoenix_live_view/node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "deps/phoenix_live_view/node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "deps/phoenix_live_view/node_modules/eslint-plugin-jest": { - "version": "28.14.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.14.0.tgz", - "integrity": "sha512-P9s/qXSMTpRTerE2FQ0qJet2gKbcGyFTPAJipoKxmWqR6uuFqIqk8FuEfg5yBieOezVrEfAMZrEwJ6yEp+1MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "engines": { - "node": "^16.10.0 || ^18.12.0 || >=20.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0 || ^8.0.0", - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", - "jest": "*" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } - } - }, - "deps/phoenix_live_view/node_modules/eslint/node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "deps/phoenix_live_view/node_modules/phoenix": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.21.tgz", - "integrity": "sha512-8wOvJ8pQXRxNbyFlMI+wQhK3bvX4Ps3FtUX2+0cV6lkcebe9VTIl+xS60FLAeaPieFg80djor5z/AKofnwqAUw==", - "dev": true, - "license": "MIT" - }, - "deps/phoenix_live_view/node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "deps/phoenix_vite": { - "dev": true, - "license": "Apache-2.0", - "devDependencies": { - "@types/node": "^22.9.1", - "typescript": "^5.8.0", - "vite": "^7.0" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "deps/phoenix_vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "@babel/core": "^7.0.0-0" } }, - "deps/phoenix_vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "deps/phoenix_live_view/node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "funding": { + "url": "https://eslint.org/donate" } }, - "deps/phoenix_vite/node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "deps/phoenix_live_view/node_modules/eslint": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.29.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" }, "bin": { - "vite": "bin/vite.js" + "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "url": "https://eslint.org/donate" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "jiti": "*" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, "jiti": { "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { + } + } + }, + "deps/phoenix_live_view/node_modules/eslint-plugin-jest": { + "version": "28.14.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.14.0.tgz", + "integrity": "sha512-P9s/qXSMTpRTerE2FQ0qJet2gKbcGyFTPAJipoKxmWqR6uuFqIqk8FuEfg5yBieOezVrEfAMZrEwJ6yEp+1MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { "optional": true }, - "yaml": { + "jest": { "optional": true } } }, + "deps/phoenix_live_view/node_modules/eslint/node_modules/@eslint/js": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "deps/phoenix_live_view/node_modules/phoenix": { + "version": "1.7.21", + "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.21.tgz", + "integrity": "sha512-8wOvJ8pQXRxNbyFlMI+wQhK3bvX4Ps3FtUX2+0cV6lkcebe9VTIl+xS60FLAeaPieFg80djor5z/AKofnwqAUw==", + "dev": true, + "license": "MIT" + }, + "deps/phoenix_live_view/node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "deps/phoenix_vite": { + "dev": true, + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.9.1", + "typescript": "^5.8.0", + "vite": "^7.0" + } + }, "deps/phoenix/node_modules/jsdom": { "version": "28.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", @@ -1067,6 +530,10 @@ } } }, + "deps/salad_ui": { + "version": "1.0.0", + "license": "MIT" + }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -3437,9 +2904,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -3471,9 +2938,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -3505,9 +2972,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -3751,33 +3218,95 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", + "vue-demi": ">=0.13.0" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" }, "peerDependenciesMeta": { - "@noble/hashes": { + "@vue/composition-api": { "optional": true } } @@ -3834,6 +3363,24 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz", + "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4534,6 +4081,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/vue": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.21.0.tgz", + "integrity": "sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4602,6 +4158,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-icons/vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-icons/vue/-/vue-1.0.0.tgz", + "integrity": "sha512-gKWWk9tTK/laDRRNe5KLLR8A0qUwx4q4+DN8Fq48hJ904u78R82ayAO3TrxbNLgyn2D0h6rRiGdLzQWj7rPcvA==", + "license": "MIT", + "peerDependencies": { + "vue": ">= 3" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -5000,6 +4572,15 @@ "eslint": "^9.0.0 || ^10.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -5272,6 +4853,32 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", + "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.29", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.29.tgz", + "integrity": "sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -6141,29 +5748,32 @@ ] }, "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "dev": true, "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -6172,13 +5782,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6209,9 +5819,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { @@ -6222,13 +5832,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -6237,13 +5847,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6252,9 +5862,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6265,13 +5875,13 @@ } }, "node_modules/@vitest/ui": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", - "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.6.tgz", + "integrity": "sha512-mATfG3zVdhobE9U1rIpvtYD3DGuSSxqZ3Aj/8ityGqKXy8YDJ9BoAjZmAz6dZ1IZ1xI5V+MerkCczvVa+3QK9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", @@ -6283,17 +5893,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "3.2.4" + "vitest": "3.2.6" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6493,14 +6103,14 @@ "license": "MIT" }, "node_modules/@vueuse/core": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", - "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "13.9.0", - "@vueuse/shared": "13.9.0" + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -6510,18 +6120,18 @@ } }, "node_modules/@vueuse/metadata": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", - "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", - "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -6726,6 +6336,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7239,6 +6861,18 @@ "dev": true, "license": "MIT" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -7276,6 +6910,15 @@ "node": ">=8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7466,16 +7109,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/daisyui": { - "version": "5.5.19", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", - "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/saadeghi/daisyui?sponsor=1" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -7585,6 +7218,12 @@ "node": ">=0.10.0" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -8464,9 +8103,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -12567,6 +12206,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -13427,6 +13072,31 @@ "regjsparser": "bin/parser" } }, + "node_modules/reka-ui": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.0.tgz", + "integrity": "sha512-HIUVfSBM/AyGkcUI7aiOxxMc4N+0UD2ZEun8dcrT0H4fveotEoeDdvzyZu97eeEvEa1H9oGHoOpApkfxlgnC7g==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@floating-ui/vue": "^1.1.6", + "@internationalized/date": "^3.5.0", + "@internationalized/number": "^3.5.0", + "@tanstack/vue-virtual": "^3.12.0", + "@vueuse/core": "^14.1.0", + "@vueuse/shared": "^14.1.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.5", + "ohash": "^2.0.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/zernonia" + }, + "peerDependencies": { + "vue": ">= 3.4.0" + } + }, "node_modules/remark": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.3.tgz", @@ -13679,6 +13349,10 @@ "dev": true, "license": "MIT" }, + "node_modules/salad_ui": { + "resolved": "deps/salad_ui", + "link": true + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -14130,6 +13804,16 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -14437,9 +14121,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -14451,6 +14133,15 @@ "node": ">=0.6.x" } }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14986,24 +14677,24 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -15012,14 +14703,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -15084,9 +14775,9 @@ } }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -15101,9 +14792,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -15118,9 +14809,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -15135,9 +14826,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -15152,9 +14843,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -15169,9 +14860,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -15186,9 +14877,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -15203,9 +14894,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -15220,9 +14911,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -15237,9 +14928,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -15254,9 +14945,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -15271,9 +14962,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -15288,9 +14979,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -15305,9 +14996,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -15322,9 +15013,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -15339,9 +15030,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -15356,9 +15047,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -15373,9 +15064,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -15390,9 +15081,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -15407,9 +15098,9 @@ } }, "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -15424,9 +15115,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -15441,9 +15132,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -15458,9 +15149,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -15475,9 +15166,9 @@ } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -15488,49 +15179,49 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -15560,8 +15251,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index 298c88f0..31c80481 100644 --- a/package.json +++ b/package.json @@ -4,23 +4,31 @@ "e2e:test:headed": "playwright test --config test/e2e/playwright.config.ts --headed" }, "dependencies": { - "@vueuse/core": "^13.7.0", + "@lucide/vue": "^1.21.0", + "@radix-icons/vue": "^1.0.0", + "@vueuse/core": "^14.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "live_vue": "file:./deps/live_vue", "phoenix": "file:./deps/phoenix", "phoenix_html": "file:./deps/phoenix_html", "phoenix_live_view": "file:./deps/phoenix_live_view", + "reka-ui": "^2.10.0", + "salad_ui": "file:deps/salad_ui", + "tailwind-merge": "^3.6.0", "topbar": "^3.0.0", - "vue": "^3.4.21" + "tw-animate-css": "^1.4.0", + "vue": "^3.5.21" }, "devDependencies": { "@playwright/test": "^1.54.2", - "@tailwindcss/vite": "^4.1.0", - "@vitejs/plugin-vue": "^5.0.4", - "daisyui": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@vitejs/plugin-vue": "^6.0.7", "phoenix_vite": "file:./deps/phoenix_vite", - "tailwindcss": "^4.1.0", + "tailwindcss": "^4.1.7", "typescript": "^5.4.5", - "vite": "^6.3.0", + "vite": "^7.3.5", + "vitest": "^3.2.6", "vue-tsc": "^2.0.13" } } diff --git a/test/jido_code_web/components/live_vue_components_test.exs b/test/jido_code_web/components/live_vue_components_test.exs deleted file mode 100644 index aca3540c..00000000 --- a/test/jido_code_web/components/live_vue_components_test.exs +++ /dev/null @@ -1,88 +0,0 @@ -defmodule JidoCodeWeb.LiveVueComponentsTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.live_vue_is_canonical_rich_component_bridge - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.testing_keeps_liveview_and_adds_live_vue_aware_helpers - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - use ExUnit.Case, async: true - - import JidoCodeWeb.LiveVueCase - import Phoenix.LiveViewTest - - setup do - original_override = Application.get_env(:jido_code, :frontend_assets_override, :__missing__) - - on_exit(fn -> - case original_override do - :__missing__ -> Application.delete_env(:jido_code, :frontend_assets_override) - value -> Application.put_env(:jido_code, :frontend_assets_override, value) - end - end) - - :ok - end - - test "vue_surface normalizes event bindings into JS handlers" do - html = - render_component(&JidoCodeWeb.LiveVueComponents.vue_surface/1, %{ - component: "BoundaryProbe", - events: %{"request-sync" => "request_sync"}, - id: "boundary-probe", - props: %{status: "idle"}, - socket: nil - }) - - assert_vue_component(html, "BoundaryProbe", id: "boundary-probe") - assert_vue_prop(html, :status, "idle", id: "boundary-probe") - assert_vue_handler(html, "request-sync", "request_sync", id: "boundary-probe") - end - - test "vue_surface falls back to a product-oriented compatibility notice when frontend delivery is unavailable" do - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - html = - render_component(&JidoCodeWeb.LiveVueComponents.vue_surface/1, %{ - component: "BoundaryProbe", - id: "boundary-probe", - socket: nil - }) - - assert html =~ "Interactive summary temporarily unavailable" - assert html =~ "server-rendered fallback mode" - assert html =~ "Fallback mode reason: Asset manifest unavailable" - refute html =~ ~s(phx-hook="VueHook") - end - - test "vue_surface keeps rendering in client-only mode when SSR is degraded" do - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :client_only, - reason: :ssr_unavailable - }) - - html = - render_component(&JidoCodeWeb.LiveVueComponents.vue_surface/1, %{ - component: "BoundaryProbe", - id: "boundary-probe", - props: %{status: "idle"}, - socket: nil - }) - - vue = assert_vue_component(html, "BoundaryProbe", id: "boundary-probe") - - assert vue.props["status"] == "idle" - assert vue.ssr == false - end - - test "vue_surface rejects reserved prop keys" do - assert_raise ArgumentError, ~r/reserved prop key/, fn -> - render_component(&JidoCodeWeb.LiveVueComponents.vue_surface/1, %{ - component: "BoundaryProbe", - props: %{"v-socket" => "bad"}, - socket: nil - }) - end - end -end diff --git a/test/jido_code_web/components/operator_state_components_test.exs b/test/jido_code_web/components/operator_state_components_test.exs deleted file mode 100644 index 862d2713..00000000 --- a/test/jido_code_web/components/operator_state_components_test.exs +++ /dev/null @@ -1,81 +0,0 @@ -defmodule JidoCodeWeb.OperatorStateComponentsTest do - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - use ExUnit.Case, async: true - - import Phoenix.LiveViewTest - - defmodule Harness do - use Phoenix.Component - - import JidoCodeWeb.OperatorStateComponents - - def warning(assigns) do - ~H""" - <.operator_state_notice - id="operator-state" - title="Shared operator state" - state={@state} - kind={:warning} - compact={true} - > -

Extra context

- <:actions> - - - - """ - end - - def notice(assigns) do - ~H""" - <.operator_state_notice - id="operator-state" - title="Shared operator notice" - state={@state} - kind={:notice} - dom_prefix="custom-operator-state" - /> - """ - end - end - - test "operator_state_notice renders typed state detail, remediation, and actions" do - html = - render_component(&Harness.warning/1, %{ - state: %{ - error_type: "stale_feed", - detail: "The summarized data is behind the current control-plane state.", - remediation: "Retry the affected refresh path after reviewing persistence health." - } - }) - - assert html =~ ~s(id="operator-state-label") - assert html =~ "Shared operator state" - assert html =~ ~s(id="operator-state-type") - assert html =~ "Typed warning: stale_feed" - assert html =~ ~s(id="operator-state-detail") - assert html =~ "The summarized data is behind the current control-plane state." - assert html =~ ~s(id="operator-state-remediation") - assert html =~ "Retry the affected refresh path after reviewing persistence health." - assert html =~ ~s(id="operator-state-extra") - assert html =~ ~s(id="operator-state-action") - end - - test "operator_state_notice can change the DOM prefix without changing the section id" do - html = - render_component(&Harness.notice/1, %{ - state: %{ - error_type: "filter_reset_notice", - detail: "Invalid values were replaced with product defaults." - } - }) - - assert html =~ ~s(id="operator-state") - assert html =~ ~s(id="custom-operator-state-label") - assert html =~ ~s(id="custom-operator-state-type") - assert html =~ ~s(id="custom-operator-state-detail") - assert html =~ "Typed notice: filter_reset_notice" - refute html =~ "custom-operator-state-remediation" - end -end diff --git a/test/jido_code_web/controllers/provider_auth_controller_test.exs b/test/jido_code_web/controllers/provider_auth_controller_test.exs index 4938988b..36b4505f 100644 --- a/test/jido_code_web/controllers/provider_auth_controller_test.exs +++ b/test/jido_code_web/controllers/provider_auth_controller_test.exs @@ -82,8 +82,8 @@ defmodule JidoCodeWeb.ProviderAuthControllerTest do |> get(~p"/welcome") |> html_response(200) - assert welcome_html =~ "octocat@example.com" - assert welcome_html =~ "Sign Out" + assert welcome_html =~ ~s|id="jido-code-reset-surface"| + assert welcome_html =~ "Welcome" {:ok, identities} = UserIdentity.list_for_user(%{user_id: current_user_id(conn)}, authorize?: false) assert Enum.map(identities, & &1.provider_subject) == ["12345"] diff --git a/test/jido_code_web/frontend_assets_test.exs b/test/jido_code_web/frontend_assets_test.exs index 3cd8ac7d..17fd50a7 100644 --- a/test/jido_code_web/frontend_assets_test.exs +++ b/test/jido_code_web/frontend_assets_test.exs @@ -36,7 +36,7 @@ defmodule JidoCodeWeb.FrontendAssetsTest do }) status = FrontendAssets.status() - delivery = FrontendAssets.vue_surface_delivery("DashboardRunSummaryWidget") + delivery = FrontendAssets.vue_surface_delivery("ResetSurface") assert status.mode == :fallback assert delivery.mode == :fallback @@ -54,7 +54,7 @@ defmodule JidoCodeWeb.FrontendAssetsTest do }) status = FrontendAssets.status() - delivery = FrontendAssets.vue_surface_delivery("DashboardRunSummaryWidget") + delivery = FrontendAssets.vue_surface_delivery("ResetSurface") assert status.mode == :client_only assert delivery.mode == :client_only diff --git a/test/jido_code_web/integration/self_hosted_provider_auth_test.exs b/test/jido_code_web/integration/self_hosted_provider_auth_test.exs deleted file mode 100644 index b5f2a0d3..00000000 --- a/test/jido_code_web/integration/self_hosted_provider_auth_test.exs +++ /dev/null @@ -1,350 +0,0 @@ -defmodule JidoCodeWeb.SelfHostedProviderAuthTest do - # covers: auth.self_hosted_provider_integration.login_and_service_ready - # covers: auth.self_hosted_provider_integration.service_independent_of_login_toggle - # covers: auth.self_hosted_provider_integration.local_auth_fallback_on_broker_failure - # covers: auth.self_hosted_provider_integration.allowlist_rejection_without_service_regression - # covers: auth.self_hosted_provider_integration.bootstrap_precedes_provider_login - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias AshAuthentication.{Info, Strategy} - alias JidoCode.Accounts.User - alias JidoCode.AuthProviders.{BrokerNonceStore, BrokerState, ProviderConfig} - alias JidoCode.Repo - - @resolver_env :provider_auth_broker_jwks_resolver - @managed_app_env_keys [ - :setup_github_credential_checker, - :github_app_id, - :github_app_private_key, - :github_app_installation_token, - :github_app_accessible_repos, - :github_app_expected_repos, - :github_pat, - :github_pat_accessible_repos - ] - - setup do - BrokerNonceStore.reset!() - - original_resolver = Application.get_env(:jido_code, @resolver_env, :__missing__) - - original_app_env = - Map.new(@managed_app_env_keys, fn key -> - {key, Application.get_env(:jido_code, key, :__missing__)} - end) - - on_exit(fn -> - case original_resolver do - :__missing__ -> Application.delete_env(:jido_code, @resolver_env) - value -> Application.put_env(:jido_code, @resolver_env, value) - end - - Enum.each(original_app_env, fn {key, value} -> - case value do - :__missing__ -> Application.delete_env(:jido_code, key) - restored -> Application.put_env(:jido_code, key, restored) - end - end) - end) - - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - - :ok - end - - test "self-hosted deployment supports provider login and GitHub service auth together", %{ - conn: conn - } do - bootstrap_owner("owner@example.com", "owner-password-123") - configure_github_service_ready!() - enable_provider_login!(:github, "github.com") - {issued_state, handoff_token} = valid_broker_handoff("integration-enabled") - - response_conn = - get( - conn, - ~p"/auth/providers/github/complete?provider_host=github.com&state=#{issued_state.token}&handoff_token=#{handoff_token}" - ) - - assert redirected_to(response_conn, 302) == "/dashboard" - - {:ok, dashboard_view, _html} = live(recycle(response_conn), ~p"/dashboard") - - assert render(dashboard_view) =~ "octocat@example.com" - assert has_element?(dashboard_view, "#dashboard-entry-summary", "authenticated product home") - - {:ok, settings_view, _html} = live(recycle(response_conn), ~p"/settings/auth") - - assert has_element?(settings_view, "#settings-auth-provider-login-card-github", "Ready") - assert has_element?(settings_view, "#settings-auth-github-service-status", "GitHub automation is ready.") - assert has_element?(settings_view, "#settings-auth-github-service-path-github_app", "Ready") - end - - test "GitHub service auth remains available when provider login is disabled", %{conn: conn} do - bootstrap_owner("owner@example.com", "owner-password-123") - configure_github_service_ready!() - enable_provider_login!(:github, "github.com", enabled: false, login_enabled: false) - {issued_state, handoff_token} = valid_broker_handoff("integration-disabled") - - response = - conn - |> get( - ~p"/auth/providers/github/complete?provider_host=github.com&state=#{issued_state.token}&handoff_token=#{handoff_token}" - ) - |> json_response(403) - - assert response["error"]["error_type"] == "provider_login_disabled" - - {authed_conn, _session_token, _owner} = - authenticate_bootstrap_owner_conn("owner@example.com", "owner-password-123") - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/auth") - - assert has_element?(view, "#settings-auth-provider-login-card-github", "Disabled") - assert has_element?(view, "#settings-auth-github-service-status", "GitHub automation is ready.") - end - - test "broker unavailability keeps local email auth as the fallback path", %{conn: conn} do - bootstrap_owner("owner@example.com", "owner-password-123") - enable_provider_login!(:github, "github.com") - {:ok, issued_state} = issue_state("broker-unavailable") - - Application.put_env(:jido_code, @resolver_env, fn _config -> - {:error, :jwks_unavailable} - end) - - response = - conn - |> get( - ~p"/auth/providers/github/complete?provider_host=github.com&state=#{issued_state.token}&handoff_token=unavailable-token" - ) - |> json_response(401) - - assert response["error"]["error_type"] == "broker_jwks_unavailable" - - {:ok, anonymous_view, _html} = live(build_conn(), ~p"/welcome") - - assert has_element?(anonymous_view, "a", "Sign In") - refute has_element?(anonymous_view, "a", "Create Account") - - assert has_element?( - anonymous_view, - ~s|a[href="/auth/providers/github/start?provider_host=github.com"]|, - "Sign In with GitHub" - ) - - bootstrap_owner("fallback@example.com", "fallback-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_bootstrap_owner_conn("fallback@example.com", "fallback-password-123") - - {:ok, authed_view, _html} = live(recycle(authed_conn), ~p"/welcome") - - assert render(authed_view) =~ "fallback@example.com" - assert has_element?(authed_view, "a[href=\"/sign-out\"]", "Sign Out") - end - - test "allowlist rejection blocks provider login without breaking GitHub service readiness", %{ - conn: conn - } do - bootstrap_owner("owner@example.com", "owner-password-123") - configure_github_service_ready!() - - enable_provider_login!(:github, "github.com", - allowlist_mode: :organizations, - allowlist_values: ["agentjido"] - ) - - {issued_state, handoff_token} = - valid_broker_handoff("allowlist-rejection", %{ - "provider_email" => "blocked@example.com", - "organizations" => ["different-org"] - }) - - response_conn = - get( - conn, - ~p"/auth/providers/github/complete?provider_host=github.com&state=#{issued_state.token}&handoff_token=#{handoff_token}" - ) - - response = json_response(response_conn, 422) - - assert response["error"]["error_type"] == "provider_identity_not_allowlisted" - assert get_session(response_conn, "user_token") == nil - - {authed_conn, _session_token, _owner} = - authenticate_bootstrap_owner_conn("owner@example.com", "owner-password-123") - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/auth") - - assert has_element?(view, "#settings-auth-provider-login-card-github", "Ready") - assert has_element?(view, "#settings-auth-github-service-status", "GitHub automation is ready.") - end - - defp configure_github_service_ready! do - Application.delete_env(:jido_code, :setup_github_credential_checker) - Application.put_env(:jido_code, :github_app_id, "1234") - Application.put_env(:jido_code, :github_app_private_key, "test-private-key") - Application.put_env(:jido_code, :github_app_installation_token, "installation-token") - Application.put_env(:jido_code, :github_app_accessible_repos, ["agentjido/jido_code"]) - Application.delete_env(:jido_code, :github_app_expected_repos) - Application.delete_env(:jido_code, :github_pat) - Application.delete_env(:jido_code, :github_pat_accessible_repos) - end - - defp issue_state(nonce, redirect_path \\ "/dashboard") do - BrokerState.issue( - %{ - provider: "github", - provider_host: "github.com", - broker_base_url: "https://broker.example.com", - redirect_path: redirect_path - }, - now: current_now(), - nonce: nonce - ) - end - - defp valid_broker_handoff(nonce, claim_overrides \\ %{}) do - {:ok, issued_state} = issue_state(nonce) - - jwk = JOSE.JWK.generate_key({:okp, :Ed25519}) - - Application.put_env(:jido_code, @resolver_env, fn _config -> - {:ok, %{"keys" => [public_jwk_map(jwk)]}} - end) - - handoff_token = - handoff_token( - jwk, - %{ - "nonce" => nonce, - "exp" => DateTime.to_unix(DateTime.add(DateTime.utc_now(), 300, :second)) - } - |> Map.merge(claim_overrides) - ) - - {issued_state, handoff_token} - end - - defp enable_provider_login!(provider, provider_host, overrides \\ []) do - attrs = - %{ - provider: provider, - provider_host: provider_host, - enabled: true, - login_enabled: true, - allowlist_mode: :none, - allowlist_values: [], - broker_issuer: "https://broker.example.com", - broker_audience: "jido-code", - broker_base_url: "https://broker.example.com" - } - |> Map.merge(Map.new(overrides)) - - {:ok, config} = ProviderConfig.upsert(attrs, authorize?: false) - config - end - - defp bootstrap_owner(email, password) do - case User.bootstrap_admin( - %{ - email: email, - password: password, - password_confirmation: password - }, - authorize?: false - ) do - {:ok, _owner} -> - :ok - - {:error, reason} -> - if Exception.message(reason) =~ "has already been taken" do - :ok - else - raise "owner bootstrap failed: #{Exception.message(reason)}" - end - end - end - - defp authenticate_bootstrap_owner_conn(email, password) do - strategy = Info.strategy!(User, :password) - - {:ok, owner} = - Strategy.action( - strategy, - :sign_in, - %{"email" => email, "password" => password}, - context: %{token_type: :sign_in} - ) - - token = - owner - |> Map.get(:__metadata__, %{}) - |> Map.fetch!(:token) - - auth_response = build_conn() |> get(bootstrap_owner_sign_in_with_token_path(strategy, token)) - assert redirected_to(auth_response, 302) == "/dashboard" - session_token = get_session(auth_response, "user_token") - assert is_binary(session_token) - {recycle(auth_response), session_token, owner} - end - - defp bootstrap_owner_sign_in_with_token_path(strategy, token) do - strategy_path = - strategy - |> Strategy.routes() - |> Enum.find_value(fn - {path, :sign_in_with_token} -> path - _other -> nil - end) - - path = - Path.join( - "/auth", - String.trim_leading(strategy_path || "/user/password/sign_in_with_token", "/") - ) - - query = URI.encode_query(%{"token" => token}) - "#{path}?#{query}" - end - - defp handoff_token(jwk, overrides) do - claims = - Map.merge( - %{ - "iss" => "https://broker.example.com", - "aud" => "jido-code", - "nonce" => "integration-nonce", - "provider" => "github", - "provider_host" => "github.com", - "provider_subject" => "12345", - "provider_login" => "octocat", - "provider_email" => "octocat@example.com", - "email_verified" => true, - "organizations" => ["agentjido"], - "exp" => DateTime.to_unix(DateTime.add(DateTime.utc_now(), 300, :second)) - }, - overrides - ) - - {_, compact} = - jwk - |> JOSE.JWT.sign(%{"alg" => "EdDSA"}, JOSE.JWT.from_map(claims)) - |> JOSE.JWS.compact() - - compact - end - - defp public_jwk_map(jwk) do - jwk - |> JOSE.JWK.to_public_map() - |> elem(1) - end - - defp current_now do - DateTime.utc_now() |> DateTime.truncate(:second) - end -end diff --git a/test/jido_code_web/live/agents_live_test.exs b/test/jido_code_web/live/agents_live_test.exs deleted file mode 100644 index aee3ec92..00000000 --- a/test/jido_code_web/live/agents_live_test.exs +++ /dev/null @@ -1,446 +0,0 @@ -defmodule JidoCodeWeb.AgentsLiveTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - setup do - original_project_loader = - Application.get_env(:jido_code, :support_agent_config_project_loader, :__missing__) - - original_project_updater = - Application.get_env(:jido_code, :support_agent_config_project_updater, :__missing__) - - on_exit(fn -> - restore_env(:support_agent_config_project_loader, original_project_loader) - restore_env(:support_agent_config_project_updater, original_project_updater) - end) - - :ok - end - - test "agents page exposes enable and disable controls and persists issue bot enabled state per project", - %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo_enabled} = - provision_managed_repo!(%{ - name: "repo-enabled", - github_full_name: "owner/repo-enabled", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{"enabled" => true} - } - } - }) - - %{managed_repo: repo_disabled} = - provision_managed_repo!(%{ - name: "repo-disabled", - github_full_name: "owner/repo-disabled", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{"enabled" => false} - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - assert has_element?(view, "#agents-project-table") - - assert has_element?( - view, - "#agents-project-github-full-name-#{repo_enabled.id}", - "owner/repo-enabled" - ) - - assert has_element?( - view, - "#agents-project-github-full-name-#{repo_disabled.id}", - "owner/repo-disabled" - ) - - assert has_element?(view, "#agents-issue-bot-enable-#{repo_enabled.id}") - assert has_element?(view, "#agents-issue-bot-disable-#{repo_enabled.id}") - assert has_element?(view, "#agents-issue-bot-enable-#{repo_disabled.id}") - assert has_element?(view, "#agents-issue-bot-disable-#{repo_disabled.id}") - - assert has_element?(view, "#agents-issue-bot-status-#{repo_enabled.id}", "Enabled") - assert has_element?(view, "#agents-issue-bot-status-#{repo_disabled.id}", "Disabled") - - view - |> element("#agents-issue-bot-disable-#{repo_enabled.id}") - |> render_click() - - assert has_element?(view, "#agents-issue-bot-status-#{repo_enabled.id}", "Disabled") - - refreshed_repo_enabled = read_managed_repo!(repo_enabled.id) - assert issue_bot_enabled(refreshed_repo_enabled.integration_settings) == false - - view - |> element("#agents-issue-bot-enable-#{repo_disabled.id}") - |> render_click() - - assert has_element?(view, "#agents-issue-bot-status-#{repo_disabled.id}", "Enabled") - - refreshed_repo_disabled = read_managed_repo!(repo_disabled.id) - assert issue_bot_enabled(refreshed_repo_disabled.integration_settings) == true - end - - test "persistence failure leaves enabled state unchanged and renders typed error feedback", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-failure", - github_full_name: "owner/repo-failure", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{"enabled" => true} - } - } - }) - - Application.put_env(:jido_code, :support_agent_config_project_updater, fn _project, _update_attributes -> - {:error, :forced_support_agent_config_failure} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - assert has_element?(view, "#agents-issue-bot-status-#{repo.id}", "Enabled") - - view - |> element("#agents-issue-bot-disable-#{repo.id}") - |> render_click() - - assert has_element?( - view, - "#agents-issue-bot-error-type", - "support_agent_config_persistence_failed" - ) - - assert has_element?(view, "#agents-issue-bot-status-#{repo.id}", "Enabled") - - refreshed_repo = read_managed_repo!(repo.id) - assert issue_bot_enabled(refreshed_repo.integration_settings) == true - end - - test "agents page persists explicit per-project webhook event list updates", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-events", - github_full_name: "owner/repo-events", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{ - "enabled" => true, - "webhook_events" => ["issues.opened"] - } - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - assert has_element?( - view, - "#agents-issue-bot-event-checkbox-#{repo.id}-issues-opened" - ) - - assert has_element?( - view, - "#agents-issue-bot-event-checkbox-#{repo.id}-issues-edited" - ) - - assert has_element?( - view, - "#agents-issue-bot-event-checkbox-#{repo.id}-issue-comment-created" - ) - - view - |> element("#agents-issue-bot-events-form-#{repo.id}") - |> render_submit(%{ - "project_id" => repo.id, - "webhook_events" => ["issues.edited", "issue_comment.created"] - }) - - refreshed_repo = read_managed_repo!(repo.id) - - assert issue_bot_webhook_events(refreshed_repo.integration_settings) == [ - "issues.edited", - "issue_comment.created" - ] - end - - test "agents page rejects unsupported webhook event values with typed validation errors", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-event-validation", - github_full_name: "owner/repo-event-validation", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{ - "enabled" => true, - "webhook_events" => ["issues.opened"] - } - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - view - |> element("#agents-issue-bot-events-form-#{repo.id}") - |> render_submit(%{ - "project_id" => repo.id, - "webhook_events" => ["issues.opened", "issues.invalid_action"] - }) - - assert has_element?( - view, - "#agents-issue-bot-error-type", - "support_agent_config_validation_failed" - ) - - assert has_element?( - view, - "#agents-issue-bot-error-detail", - "issues.invalid_action" - ) - - refreshed_repo = read_managed_repo!(repo.id) - assert issue_bot_webhook_events(refreshed_repo.integration_settings) == ["issues.opened"] - end - - test "agents page supports per-project approval mode selection and persists issue bot policy", - %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-approval-mode", - github_full_name: "owner/repo-approval-mode", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{ - "enabled" => true, - "approval_mode" => "manual" - } - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - assert has_element?( - view, - "#agents-issue-bot-approval-select-#{repo.id} option[value='auto_post']", - "Auto-post" - ) - - assert has_element?( - view, - "#agents-issue-bot-approval-select-#{repo.id} option[value='approval_required']", - "Approval required" - ) - - assert has_element?( - view, - "#agents-issue-bot-approval-mode-#{repo.id}", - "Approval required" - ) - - assert has_element?(view, "#agents-issue-bot-last-updated-#{repo.id}", "Last updated:") - - view - |> element("#agents-issue-bot-approval-form-#{repo.id}") - |> render_submit(%{ - "project_id" => repo.id, - "approval_mode" => "auto_post" - }) - - assert has_element?( - view, - "#agents-issue-bot-approval-mode-#{repo.id}", - "Auto-post" - ) - - refreshed_repo = read_managed_repo!(repo.id) - assert issue_bot_approval_mode(refreshed_repo.integration_settings) == "auto_post" - end - - test "approval mode persistence failure keeps prior policy and renders typed failure context", - %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-approval-mode-failure", - github_full_name: "owner/repo-approval-mode-failure", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{ - "enabled" => true, - "approval_mode" => "approval_required" - } - } - } - }) - - Application.put_env(:jido_code, :support_agent_config_project_updater, fn _project, _update_attributes -> - {:error, :forced_support_agent_config_failure} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - assert has_element?( - view, - "#agents-issue-bot-approval-mode-#{repo.id}", - "Approval required" - ) - - view - |> element("#agents-issue-bot-approval-form-#{repo.id}") - |> render_submit(%{ - "project_id" => repo.id, - "approval_mode" => "auto_post" - }) - - assert has_element?( - view, - "#agents-issue-bot-error-type", - "support_agent_config_persistence_failed" - ) - - assert has_element?( - view, - "#agents-issue-bot-approval-mode-#{repo.id}", - "Approval required" - ) - - refreshed_repo = read_managed_repo!(repo.id) - assert issue_bot_approval_mode(refreshed_repo.integration_settings) == "approval_required" - end - - defp issue_bot_enabled(settings) when is_map(settings) do - settings - |> map_get(:support_agent_config, "support_agent_config", %{}) - |> normalize_map() - |> map_get(:github_issue_bot, "github_issue_bot", %{}) - |> normalize_map() - |> map_get(:enabled, "enabled", true) - |> case do - true -> true - "true" -> true - false -> false - "false" -> false - _other -> true - end - end - - defp issue_bot_enabled(_settings), do: true - - defp issue_bot_webhook_events(settings) when is_map(settings) do - settings - |> map_get(:support_agent_config, "support_agent_config", %{}) - |> normalize_map() - |> map_get(:github_issue_bot, "github_issue_bot", %{}) - |> normalize_map() - |> map_get(:webhook_events, "webhook_events", []) - |> case do - webhook_events when is_list(webhook_events) -> webhook_events - _other -> [] - end - end - - defp issue_bot_webhook_events(_settings), do: [] - - defp issue_bot_approval_mode(settings) when is_map(settings) do - settings - |> map_get(:support_agent_config, "support_agent_config", %{}) - |> normalize_map() - |> map_get(:github_issue_bot, "github_issue_bot", %{}) - |> normalize_map() - |> map_get(:approval_mode, "approval_mode", "approval_required") - |> case do - approval_mode when is_binary(approval_mode) -> - String.trim(approval_mode) - - approval_mode when is_atom(approval_mode) -> - approval_mode |> Atom.to_string() |> String.trim() - - _other -> - "approval_required" - end - |> case do - "auto_post" -> "auto_post" - "auto-post" -> "auto_post" - "auto" -> "auto_post" - "approval_required" -> "approval_required" - "approval-required" -> "approval_required" - "manual" -> "approval_required" - "manual_gate" -> "approval_required" - "manual-gate" -> "approval_required" - _other -> "approval_required" - end - end - - defp issue_bot_approval_mode(_settings), do: "approval_required" - - defp normalize_map(value) when is_map(value), do: value - defp normalize_map(_value), do: %{} - - defp map_get(map, atom_key, string_key, default) - - defp map_get(map, atom_key, string_key, default) when is_map(map) do - cond do - Map.has_key?(map, atom_key) -> Map.get(map, atom_key) - Map.has_key?(map, string_key) -> Map.get(map, string_key) - true -> default - end - end - - defp map_get(_map, _atom_key, _string_key, default), do: default -end diff --git a/test/jido_code_web/live/auth_boundary_live_test.exs b/test/jido_code_web/live/auth_boundary_live_test.exs deleted file mode 100644 index e0315d84..00000000 --- a/test/jido_code_web/live/auth_boundary_live_test.exs +++ /dev/null @@ -1,53 +0,0 @@ -defmodule JidoCodeWeb.AuthBoundaryLiveTest do - # covers: auth.system.revocable_credentials - use JidoCodeWeb.ConnCase, async: false - - alias AshAuthentication.TokenResource.Actions - alias JidoCode.Accounts.Token - - test "anonymous landing requests stay anonymous", %{conn: conn} do - welcome_html = - conn - |> get(~p"/welcome") - |> html_response(200) - - assert welcome_html =~ "Create your admin account" - refute welcome_html =~ "Sign Out" - end - - test "authenticated local sessions render signed-in landing state", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - assert is_binary(session_token) - - welcome_html = - authed_conn - |> get(~p"/welcome") - |> html_response(200) - - assert welcome_html =~ "owner@example.com" - assert welcome_html =~ "Sign Out" - end - - test "revoked local sessions fall back to anonymous landing state and clear the stale session", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - assert is_binary(session_token) - assert :ok = Actions.revoke(Token, session_token) - - response = - authed_conn - |> recycle() - |> get(~p"/welcome") - - welcome_html = html_response(response, 200) - - assert welcome_html =~ "Sign In" - refute welcome_html =~ "Create Account" - refute welcome_html =~ "owner@example.com" - refute welcome_html =~ "Sign Out" - assert get_session(response, "user_token") == nil - end -end diff --git a/test/jido_code_web/live/auth_session_live_test.exs b/test/jido_code_web/live/auth_session_live_test.exs deleted file mode 100644 index bfc2671c..00000000 --- a/test/jido_code_web/live/auth_session_live_test.exs +++ /dev/null @@ -1,141 +0,0 @@ -defmodule JidoCodeWeb.AuthSessionLiveTest do - # covers: auth.system.local_email_identity - # covers: auth.system.password_registration_and_sign_in - # covers: auth.system.ready_state_local_auth_handoff - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias AshAuthentication.{Info, Strategy} - alias JidoCode.Accounts.User - - test "valid local credentials create a browser session and enter the dashboard by default", %{ - conn: conn - } do - register_owner("owner@example.com", "owner-password-123") - - {:ok, sign_in_view, _html} = live(conn, ~p"/sign-in", on_error: :warn) - - sign_in_view - |> form("form[action='/auth/user/password/sign_in']", %{ - "user" => %{"email" => "owner@example.com", "password" => "owner-password-123"} - }) - |> render_submit() - - auth_redirect_path = - sign_in_view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/dashboard" - - session_token = get_session(auth_response, "user_token") - assert is_binary(session_token) - - welcome_html = - auth_response - |> recycle() - |> get(~p"/welcome") - |> html_response(200) - - assert welcome_html =~ "owner@example.com" - assert welcome_html =~ "Sign Out" - end - - test "sign-in preserves an explicit return_to over the ready-state dashboard default", %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - strategy = Info.strategy!(User, :password) - - {:ok, owner} = - Strategy.action( - strategy, - :sign_in, - %{"email" => "owner@example.com", "password" => "owner-password-123"}, - context: %{token_type: :sign_in} - ) - - token = - owner - |> Map.get(:__metadata__, %{}) - |> Map.fetch!(:token) - - auth_response = - conn - |> init_test_session(%{}) - |> put_session(:return_to, "/repos") - |> get(owner_sign_in_with_token_path(strategy, token)) - - assert redirected_to(auth_response, 302) == "/repos" - end - - test "signed-in ready-state sessions are redirected away from the sign-in route to dashboard", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, _session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - - assert {:error, {:redirect, %{to: "/dashboard"}}} = - live(recycle(authed_conn), ~p"/sign-in", on_error: :warn) - end - - test "invalid local credentials do not create a browser session and preserve anonymous landing state", %{ - conn: conn - } do - register_owner("owner@example.com", "owner-password-123") - strategy = Info.strategy!(User, :password) - - assert {:error, %AshAuthentication.Errors.AuthenticationFailed{} = auth_error} = - Strategy.action( - strategy, - :sign_in, - %{"email" => "owner@example.com", "password" => "wrong-password-123"}, - context: %{token_type: :sign_in} - ) - - assert match?(%AshAuthentication.Strategy.Password{}, auth_error.strategy) - - {:ok, sign_in_view, _html} = live(conn, ~p"/sign-in", on_error: :warn) - - sign_in_view - |> form("form[action='/auth/user/password/sign_in']", %{ - "user" => %{"email" => "owner@example.com", "password" => "wrong-password-123"} - }) - |> render_submit() - - :ok = refute_redirected(sign_in_view) - assert render(sign_in_view) =~ "Email or password was incorrect" - - welcome_html = - build_conn() - |> get(~p"/welcome") - |> html_response(200) - - assert welcome_html =~ "Sign In" - refute welcome_html =~ "Create Account" - refute welcome_html =~ "owner@example.com" - end - - defp redirect_path({path, _flash}) when is_binary(path), do: path - defp redirect_path(path) when is_binary(path), do: path - - defp owner_sign_in_with_token_path(strategy, token) do - strategy_path = - strategy - |> Strategy.routes() - |> Enum.find_value(fn - {path, :sign_in_with_token} -> path - _other -> nil - end) - - path = - Path.join( - "/auth", - String.trim_leading(strategy_path || "/user/password/sign_in_with_token", "/") - ) - - query = URI.encode_query(%{"token" => token}) - "#{path}?#{query}" - end -end diff --git a/test/jido_code_web/live/auth_sign_out_live_test.exs b/test/jido_code_web/live/auth_sign_out_live_test.exs deleted file mode 100644 index 9e17819c..00000000 --- a/test/jido_code_web/live/auth_sign_out_live_test.exs +++ /dev/null @@ -1,67 +0,0 @@ -defmodule JidoCodeWeb.AuthSignOutLiveTest do - # covers: auth.system.revocable_credentials - use JidoCodeWeb.ConnCase, async: false - - test "sign out clears browser session credentials and restores anonymous landing state", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - assert is_binary(session_token) - - sign_out_response = get(authed_conn, ~p"/sign-out") - - assert redirected_to(sign_out_response, 302) == "/" - assert Phoenix.Flash.get(sign_out_response.assigns.flash, :info) == "You are now signed out" - assert get_session(sign_out_response, "user_token") == nil - - welcome_html = - sign_out_response - |> recycle() - |> get(~p"/welcome") - |> html_response(200) - - assert welcome_html =~ "Sign In" - refute welcome_html =~ "Create Account" - refute welcome_html =~ "owner@example.com" - end - - test "failed session invalidation provides retry guidance and keeps session unchanged", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - assert is_binary(session_token) - - previous_invalidator = Application.get_env(:jido_code, :sign_out_invalidator) - - on_exit(fn -> - restore_sign_out_invalidator(previous_invalidator) - end) - - Application.put_env(:jido_code, :sign_out_invalidator, fn _conn, _otp_app -> - {:error, :forced_invalidation_failure} - end) - - sign_out_response = get(authed_conn, ~p"/sign-out") - - assert redirected_to(sign_out_response, 302) == "/" - - assert Phoenix.Flash.get(sign_out_response.assigns.flash, :error) == - "Sign-out could not complete. Please retry; your current session is still active." - - assert get_session(sign_out_response, "user_token") == session_token - - welcome_html = - sign_out_response - |> recycle() - |> get(~p"/welcome") - |> html_response(200) - - assert welcome_html =~ "owner@example.com" - assert welcome_html =~ "Sign Out" - end - - defp restore_sign_out_invalidator(nil), do: Application.delete_env(:jido_code, :sign_out_invalidator) - - defp restore_sign_out_invalidator(invalidator), - do: Application.put_env(:jido_code, :sign_out_invalidator, invalidator) -end diff --git a/test/jido_code_web/live/csrf_protection_live_test.exs b/test/jido_code_web/live/csrf_protection_live_test.exs deleted file mode 100644 index 15c036a9..00000000 --- a/test/jido_code_web/live/csrf_protection_live_test.exs +++ /dev/null @@ -1,113 +0,0 @@ -defmodule JidoCodeWeb.CsrfProtectionLiveTest do - # covers: architecture.policy_layers.operator_surfaces_propagate_current_actor_for_repo_mutations - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.GitHub.Repo, as: GitHubRepo - - @mutating_methods [:post, :put, :patch, :delete] - - test "browser mutating verbs on /settings require valid csrf tokens", %{conn: _conn} do - authed_conn = authenticated_owner_conn() - - for method <- @mutating_methods do - response_conn = - authed_conn - |> recycle() - |> request_with_method(method, ~p"/settings") - - assert response_conn.status in [403, 404] - end - end - - test "valid owner session with matching csrf token mutates settings state", %{conn: _conn} do - authed_conn = authenticated_owner_conn() - - settings_conn = authed_conn |> recycle() |> get(~p"/settings") - csrf_token = settings_conn |> html_response(200) |> csrf_token_from_html() - - for method <- @mutating_methods do - response_conn = - settings_conn - |> recycle() - |> put_req_header("x-csrf-token", csrf_token) - |> request_with_method(method, ~p"/settings") - - assert response_conn.status == 404 - end - - initial_repo_count = repo_count() - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings", on_error: :warn) - - view - |> element("button[phx-click='open_add_modal']") - |> render_click() - - assert has_element?(view, "#add-repo-modal") - - unique_suffix = System.unique_integer([:positive]) - owner = "csrf-owner-#{unique_suffix}" - name = "csrf-repo-#{unique_suffix}" - full_name = "#{owner}/#{name}" - - view - |> form("#add-repo-modal form", %{ - "form" => %{ - "owner" => owner, - "name" => name - } - }) - |> render_submit() - - assert has_element?(view, "#repos-list", full_name) - assert repo_count() == initial_repo_count + 1 - assert GitHubRepo.get_by_full_name!(full_name) - end - - test "invalid csrf token blocks /settings mutation attempts and keeps state unchanged", %{ - conn: _conn - } do - authed_conn = authenticated_owner_conn() - initial_repo_count = repo_count() - - response_conn = - authed_conn - |> recycle() - |> put_req_header("x-csrf-token", "invalid-csrf-token") - |> post(~p"/settings", %{ - "form" => %{ - "owner" => "forged-owner", - "name" => "forged-repo", - "webhook_secret" => "forged-secret" - } - }) - - assert response_conn.status in [403, 404] - - assert repo_count() == initial_repo_count - end - - defp authenticated_owner_conn do - unique_suffix = System.unique_integer([:positive]) - email = "owner-#{unique_suffix}@example.com" - password = "owner-password-123" - - register_owner(email, password) - - build_conn() - |> authenticate_owner_conn(email, password) - end - - defp csrf_token_from_html(html) do - [_match, csrf_token] = Regex.run(~r/ length() -end diff --git a/test/jido_code_web/live/dashboard_live_test.exs b/test/jido_code_web/live/dashboard_live_test.exs deleted file mode 100644 index 2362f1b4..00000000 --- a/test/jido_code_web/live/dashboard_live_test.exs +++ /dev/null @@ -1,939 +0,0 @@ -defmodule JidoCodeWeb.DashboardLiveTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - # covers: architecture.runtime_service_overlay.runtime_topology_details_remain_opaque_to_product - # covers: setup.onboarding.post_bootstrap_surfaces_adopt_control_plane_language - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.Actor - alias JidoCode.Control.RepoBridge - alias JidoCode.Orchestration.{Run, WorkflowRun} - alias JidoCode.Projects.Project - alias JidoCode.Repo - - setup do - original_loader = Application.get_env(:jido_code, :dashboard_run_summary_loader, :__missing__) - - original_runtime_loader = - Application.get_env(:jido_code, :dashboard_runtime_evidence_loader, :__missing__) - - original_memory_loader = - Application.get_env(:jido_code, :dashboard_memory_summary_loader, :__missing__) - - original_conversation_loader = - Application.get_env(:jido_code, :dashboard_conversation_summary_loader, :__missing__) - - original_workbench_inventory_loader = - Application.get_env(:jido_code, :workbench_inventory_loader, :__missing__) - - original_fix_workflow_launcher = - Application.get_env(:jido_code, :workbench_fix_workflow_launcher, :__missing__) - - original_issue_triage_workflow_launcher = - Application.get_env(:jido_code, :workbench_issue_triage_workflow_launcher, :__missing__) - - on_exit(fn -> - restore_env(:dashboard_run_summary_loader, original_loader) - restore_env(:dashboard_runtime_evidence_loader, original_runtime_loader) - restore_env(:dashboard_memory_summary_loader, original_memory_loader) - restore_env(:dashboard_conversation_summary_loader, original_conversation_loader) - restore_env(:workbench_inventory_loader, original_workbench_inventory_loader) - restore_env(:workbench_fix_workflow_launcher, original_fix_workflow_launcher) - - restore_env( - :workbench_issue_triage_workflow_launcher, - original_issue_triage_workflow_launcher - ) - end) - - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - - :ok - end - - test "renders recent runs with status and recency indicators", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, _session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-dashboard-recent-runs", - github_full_name: "owner/repo-dashboard-recent-runs", - default_branch: "main", - settings: %{} - }) - - now = DateTime.utc_now() |> DateTime.truncate(:second) - - {:ok, _completed_run} = - create_run(route_id, "dashboard-run-completed", DateTime.add(now, -3_600, :second), %{ - status: :completed, - current_step: "publish_pr", - current_stage: "publish_pr", - completed_at: DateTime.add(now, -3_480, :second) - }) - - {:ok, _pending_run} = - create_run(route_id, "dashboard-run-pending", DateTime.add(now, -120, :second)) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=runs", on_error: :warn) - - assert has_element?(view, "#dashboard-entry-summary", "authenticated product home") - assert has_element?(view, ~s|a[href="/settings/auth"]|, "Settings") - assert has_element?(view, "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='runs']") - assert has_element?(view, "#dashboard-shell-parent-subjects-work[aria-current='page']") - assert has_element?(view, "#dashboard-section-nav") - assert has_element?(view, "#dashboard-section-nav-runs[aria-current='page']") - assert has_element?(view, "#dashboard-run-summaries") - assert has_element?(view, "#dashboard-run-summary-fallback") - assert has_element?(view, "#dashboard-run-status-dashboard-run-completed", "completed") - assert has_element?(view, "#dashboard-run-status-dashboard-run-pending", "pending") - assert has_element?(view, "#dashboard-run-recency-dashboard-run-completed", "Started") - assert has_element?(view, "#dashboard-run-recency-dashboard-run-completed", "ago") - - vue = assert_vue_component(view, "DashboardRunSummaryWidget", id: "dashboard-run-summary-widget") - - assert vue.props["runSummaryCount"] == 2 - - assert Enum.any?(vue.props["runSummaries"], fn run_summary -> - run_summary["runId"] == "dashboard-run-completed" and - run_summary["terminal"] == true - end) - end - - test "renders managed repo inventory overview cards ordered by recent governed work", %{ - conn: _conn - } do - register_owner("dashboard-overview-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-overview-owner@example.com", "owner-password-123") - - %{route_id: older_route_id} = - provision_managed_repo!(%{ - name: "repo-dashboard-overview-older", - github_full_name: "owner/repo-dashboard-overview-older", - default_branch: "main", - settings: %{} - }) - - %{route_id: newer_route_id} = - provision_managed_repo!(%{ - name: "repo-dashboard-overview-newer", - github_full_name: "owner/repo-dashboard-overview-newer", - default_branch: "main", - settings: %{} - }) - - now = DateTime.utc_now() |> DateTime.truncate(:second) - - {:ok, _older_run} = - create_run(older_route_id, "dashboard-overview-older-run", DateTime.add(now, -7_200, :second), %{ - status: :completed, - current_step: "summarize", - current_stage: "summarize", - completed_at: DateTime.add(now, -7_080, :second) - }) - - {:ok, _newer_run} = - create_run(newer_route_id, "dashboard-overview-newer-run", DateTime.add(now, -900, :second), %{ - status: :running, - current_step: "implement", - current_stage: "implement" - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - - assert has_element?(view, "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='overview']") - assert has_element?(view, "#dashboard-shell-parent-subjects-work[aria-current='page']") - assert has_element?(view, "#dashboard-section-nav") - assert has_element?(view, "#dashboard-sidebar-shell") - assert has_element?(view, "#dashboard-overview-panel") - assert has_element?(view, "#dashboard-overview-note", "primary signed-in home for repository inventory") - refute has_element?(view, "#dashboard-overview-warning") - refute has_element?(view, "#dashboard-overview-empty-state") - assert has_element?(view, "#dashboard-overview-repository-list") - assert has_element?(view, "[id^='dashboard-overview-repository-card-']") - assert has_element?(view, "#dashboard-overview-workbench-link[href='/workbench']") - assert has_element?(view, "[id^='dashboard-overview-repository-issues-project-link-']") - assert has_element?(view, "[id^='dashboard-overview-repository-prs-project-link-']") - - html = render(view) - - assert html =~ "owner/repo-dashboard-overview-newer" - assert html =~ "owner/repo-dashboard-overview-older" - - assert rendered_fragment_index(html, "owner/repo-dashboard-overview-newer") < - rendered_fragment_index(html, "owner/repo-dashboard-overview-older") - - refute has_element?(view, "#dashboard-run-summaries") - refute has_element?(view, "#dashboard-conversation-supervision") - refute has_element?(view, "#dashboard-memory-summaries") - refute has_element?(view, "#dashboard-runtime-evidence") - end - - test "dashboard work overview keeps shared repo-detail and run follow-up paths", %{conn: _conn} do - register_owner("dashboard-followup-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-followup-owner@example.com", "owner-password-123") - - project_id = "owner-repo-follow-up" - dashboard_return_to = ~p"/dashboard?#{[subject: "work", section: "overview"]}" - encoded_return_to = URI.encode_www_form(dashboard_return_to) - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: project_id, - name: "repo-follow-up", - github_full_name: "owner/repo-follow-up", - default_branch: "main", - open_issue_count: 3, - open_pr_count: 1, - recent_activity_summary: "Recent governed work is ready for review.", - recent_activity_at: DateTime.utc_now() |> DateTime.truncate(:second), - issue_triage_policy: %{enabled: true}, - semantic_graph_hint: %{ - state: :blocked, - label: "Workspace binding needed", - detail: "Repo workspace binding is missing for semantic inspection.", - remediation: "Open repo detail to repair workspace binding.", - recovery: %{available?: true, label: "Repair workspace binding"}, - error_type: "semantic_workspace_binding_unavailable" - }, - memory_graph_hint: %{ - state: :blocked, - label: "Workspace binding needed", - detail: "Repo workspace binding is missing for memory inspection.", - remediation: "Open repo detail to repair workspace binding.", - recovery: %{available?: true, label: "Repair workspace binding"}, - error_type: "memory_workspace_binding_unavailable" - } - } - ], nil} - end) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn _kickoff_request -> - {:ok, %{run_id: "run-fix-123"}} - end) - - Application.put_env(:jido_code, :workbench_issue_triage_workflow_launcher, fn _kickoff_request -> - {:ok, %{run_id: "run-triage-456"}} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - html = render(view) - triage_run_path = "/repos/#{project_id}/runs/run-triage-456?return_to=#{encoded_return_to}" - fix_run_path = "/repos/#{project_id}/runs/run-fix-123?return_to=#{encoded_return_to}" - - assert has_element?(view, "#dashboard-overview-workbench-link[href='/workbench']") - assert html =~ ~s(href="/repos/#{project_id}?return_to=#{encoded_return_to}") - - assert has_element?(view, "#dashboard-overview-repository-issues-project-link-#{project_id}") - assert has_element?(view, "#dashboard-overview-repository-prs-project-link-#{project_id}") - assert has_element?(view, "#dashboard-overview-repository-semantic-hint-recovery-#{project_id}") - assert has_element?(view, "#dashboard-overview-repository-memory-hint-recovery-#{project_id}") - - view - |> element("#dashboard-overview-repository-issues-triage-action-#{project_id}") - |> render_click() - - assert has_element?( - view, - "#dashboard-overview-repository-issues-triage-#{project_id}-run-link[href='#{triage_run_path}']" - ) - - view - |> element("#dashboard-overview-repository-issues-fix-action-#{project_id}") - |> render_click() - - assert has_element?( - view, - "#dashboard-overview-repository-issues-fix-#{project_id}-run-link[href='#{fix_run_path}']" - ) - end - - test "dashboard repository links round-trip back to Dashboard Work", %{conn: _conn} do - register_owner("dashboard-roundtrip-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-roundtrip-owner@example.com", "owner-password-123") - - %{route_id: project_id} = - provision_managed_repo!(%{ - name: "repo-dashboard-roundtrip", - github_full_name: "owner/repo-dashboard-roundtrip", - default_branch: "main", - settings: %{} - }) - - dashboard_return_to = ~p"/dashboard?#{[subject: "work", section: "overview"]}" - - repo_detail_path = - JidoCode.ManagedRepoRoutes.project_detail_path(project_id, return_to: dashboard_return_to) - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: project_id, - name: "repo-dashboard-roundtrip", - github_full_name: "owner/repo-dashboard-roundtrip", - default_branch: "main", - open_issue_count: 1, - open_pr_count: 0, - recent_activity_summary: "Dashboard origin should stay explicit.", - recent_activity_at: DateTime.utc_now() |> DateTime.truncate(:second) - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - - view - |> element("#dashboard-overview-repository-issues-project-link-#{project_id}") - |> render_click() - - assert_redirect(view, repo_detail_path) - - {:ok, repo_view, _html} = live(recycle(authed_conn), repo_detail_path, on_error: :warn) - - assert has_element?(repo_view, "#project-detail-breadcrumb-return", "Dashboard") - - assert has_element?(repo_view, "#project-detail-return-link", "Back to Dashboard") - - repo_view - |> element("#project-detail-return-link") - |> render_click() - - assert_redirect(repo_view, dashboard_return_to) - end - - test "defaults dashboard selection to work overview and ignores unknown params", %{ - conn: _conn - } do - register_owner("dashboard-section-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-section-owner@example.com", "owner-password-123") - - {:ok, overview_view, _html} = live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - - assert has_element?( - overview_view, - "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='overview']" - ) - - {:ok, invalid_view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=unknown§ion=unknown", on_error: :warn) - - assert has_element?( - invalid_view, - "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='overview']" - ) - end - - test "route-owned dashboard subject and child selection accepts canonical subject families", %{ - conn: _conn - } do - register_owner("dashboard-route-section-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-route-section-owner@example.com", "owner-password-123") - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=conversations", on_error: :warn) - - assert has_element?(view, "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='conversations']") - assert has_element?(view, "#dashboard-shell-parent-subjects-work[aria-current='page']") - assert has_element?(view, "#dashboard-conversation-supervision") - refute has_element?(view, "#dashboard-run-summaries") - refute has_element?(view, "#dashboard-memory-summaries") - - {:ok, runtime_view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=runtime§ion=runtime", on_error: :warn) - - assert has_element?( - runtime_view, - "#dashboard-root[data-dashboard-subject='runtime'][data-dashboard-section='runtime']" - ) - - assert has_element?(runtime_view, "#dashboard-shell-parent-subjects-runtime[aria-current='page']") - assert has_element?(runtime_view, "#dashboard-runtime-evidence") - refute has_element?(runtime_view, "#dashboard-conversation-supervision") - refute has_element?(runtime_view, "#dashboard-memory-summaries") - - {:ok, fallback_view, _html} = - live(recycle(authed_conn), ~p"/dashboard?section=memory", on_error: :warn) - - assert has_element?( - fallback_view, - "#dashboard-root[data-dashboard-subject='knowledge'][data-dashboard-section='memory']" - ) - end - - test "renders breadcrumbs between route framing and selected pane chrome", %{conn: _conn} do - register_owner("dashboard-shell-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-shell-owner@example.com", "owner-password-123") - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=runs", on_error: :warn) - - html = render(view) - - assert rendered_fragment_index(html, ~s(id="dashboard-settings-handoff")) < - rendered_fragment_index(html, ~s(id="dashboard-shell-breadcrumbs")) - - assert rendered_fragment_index(html, ~s(id="dashboard-shell-breadcrumbs")) < - rendered_fragment_index(html, ~s(id="dashboard-shell-parent-subjects")) - - assert rendered_fragment_index(html, ~s(id="dashboard-shell-parent-subjects")) < - rendered_fragment_index(html, ~s(id="dashboard-section-nav")) - - assert has_element?(view, "#dashboard-breadcrumb-subject", "Work") - assert has_element?(view, "#dashboard-breadcrumb-current[aria-current='page']", "Runs") - assert has_element?(view, "#dashboard-shell-parent-subjects-work[aria-current='page']") - - assert has_element?( - view, - "#dashboard-section-nav-runs[aria-controls='dashboard-pane-runs'][aria-current='page']" - ) - - assert has_element?(view, "#dashboard-pane-runs[role='region']") - assert has_element?(view, "#dashboard-pane-runs-header") - assert has_element?(view, "#dashboard-pane-runs-middle") - assert has_element?(view, "#dashboard-pane-runs-footer") - end - - test "next_steps section is only selectable when onboarding follow-up is present", %{conn: _conn} do - register_owner("dashboard-next-steps-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("dashboard-next-steps-owner@example.com", "owner-password-123") - - {:ok, blocked_view, _html} = - live( - recycle(authed_conn), - ~p"/dashboard?subject=work§ion=next_steps", - on_error: :warn - ) - - assert has_element?( - blocked_view, - "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='overview']" - ) - - {:ok, next_steps_view, _html} = - live( - recycle(authed_conn), - ~p"/dashboard?onboarding=completed&subject=work§ion=next_steps", - on_error: :warn - ) - - assert has_element?( - next_steps_view, - "#dashboard-root[data-dashboard-subject='work'][data-dashboard-section='next_steps']" - ) - - assert has_element?(next_steps_view, "#dashboard-shell-parent-subjects-work[aria-current='page']") - assert has_element?(next_steps_view, "#dashboard-onboarding-next-actions") - assert has_element?(next_steps_view, "#dashboard-next-steps-note", "bounded onboarding completion cues") - refute has_element?(next_steps_view, "#dashboard-run-summaries") - refute has_element?(next_steps_view, "#dashboard-runtime-evidence") - end - - test "updates run summaries when runs start and complete", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, _session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - - loader_calls = start_supervised!({Agent, fn -> 0 end}, id: make_ref()) - run_id = "dashboard-live-update-#{System.unique_integer([:positive])}" - run_dom_token = run_dom_token(run_id) - - Application.put_env(:jido_code, :dashboard_run_summary_loader, fn -> - call_number = - Agent.get_and_update(loader_calls, fn call_count -> - next_call_number = call_count + 1 - {next_call_number, next_call_number} - end) - - case call_number do - 1 -> - {:ok, [], nil} - - 2 -> - {:ok, [], nil} - - 3 -> - {:ok, - [ - %{ - id: "dashboard-run-summary-#{run_id}", - run_id: run_id, - project_id: nil, - workflow_name: "implement_task", - status: "pending", - started_at: DateTime.add(DateTime.utc_now(), -90, :second), - completed_at: nil - } - ], nil} - - _other -> - {:ok, - [ - %{ - id: "dashboard-run-summary-#{run_id}", - run_id: run_id, - project_id: nil, - workflow_name: "implement_task", - status: "completed", - started_at: DateTime.add(DateTime.utc_now(), -90, :second), - completed_at: DateTime.add(DateTime.utc_now(), -30, :second) - } - ], nil} - end - end) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=runs", on_error: :warn) - - assert has_element?(view, "#dashboard-run-summaries-empty-state") - - send(view.pid, {:run_event, %{"event" => "run_started"}}) - - assert_eventually(fn -> - has_element?(view, "#dashboard-run-status-#{run_dom_token}", "pending") - end) - - send(view.pid, {:run_event, %{"event" => "run_completed"}}) - - assert_eventually(fn -> - has_element?(view, "#dashboard-run-status-#{run_dom_token}", "completed") - end) - end - - test "shows stale warning and manual refresh control when summary feed is stale", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - {authed_conn, _session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - - loader_calls = start_supervised!({Agent, fn -> 0 end}, id: make_ref()) - - run_id = "dashboard-refresh-after-stale-#{System.unique_integer([:positive])}" - run_dom_token = run_dom_token(run_id) - - Application.put_env(:jido_code, :dashboard_run_summary_loader, fn -> - call_number = - Agent.get_and_update(loader_calls, fn call_count -> - next_call_number = call_count + 1 - {next_call_number, next_call_number} - end) - - case call_number do - 1 -> - {:ok, [], - %{ - error_type: "dashboard_run_summary_feed_stale", - detail: "Run summary feed has fallen behind recent lifecycle events.", - remediation: "Refresh run summaries after validating workflow persistence health." - }} - - 2 -> - {:ok, [], - %{ - error_type: "dashboard_run_summary_feed_stale", - detail: "Run summary feed has fallen behind recent lifecycle events.", - remediation: "Refresh run summaries after validating workflow persistence health." - }} - - _other -> - {:ok, - [ - %{ - id: "dashboard-run-summary-#{run_id}", - run_id: run_id, - project_id: nil, - workflow_name: "issue_triage", - status: "completed", - started_at: DateTime.add(DateTime.utc_now(), -900, :second), - completed_at: DateTime.add(DateTime.utc_now(), -840, :second) - } - ], nil} - end - end) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=runs", on_error: :warn) - - assert has_element?(view, "#dashboard-run-summary-warning") - assert has_element?(view, "#dashboard-run-summary-warning-type", "dashboard_run_summary_feed_stale") - assert has_element?(view, "#dashboard-run-summary-refresh", "Refresh run summaries") - - view - |> element("#dashboard-run-summary-refresh") - |> render_click() - - assert_eventually(fn -> - has_element?(view, "#dashboard-run-status-#{run_dom_token}", "completed") - end) - - refute has_element?(view, "#dashboard-run-summary-warning") - end - - test "renders governed run review metadata when the summary feed includes governance state", %{conn: _conn} do - register_owner("governed-dashboard-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("governed-dashboard-owner@example.com", "owner-password-123") - - run_id = "dashboard-governed-run-#{System.unique_integer([:positive])}" - run_dom_token = run_dom_token(run_id) - - Application.put_env(:jido_code, :dashboard_run_summary_loader, fn -> - {:ok, - [ - %{ - id: "dashboard-run-summary-#{run_id}", - run_id: run_id, - project_id: Ecto.UUID.generate(), - managed_repo_id: Ecto.UUID.generate(), - workflow_name: "fix_failing_tests", - status: "awaiting_approval", - current_stage: "approval", - evidence_count: 3, - change_request_status: "open", - latest_decision: nil, - started_at: DateTime.add(DateTime.utc_now(), -600, :second), - completed_at: nil - } - ], nil} - end) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=runs", on_error: :warn) - - assert has_element?(view, "#dashboard-run-status-#{run_dom_token}", "awaiting_approval") - assert has_element?(view, "#dashboard-run-governance-#{run_dom_token}", "Stage: approval") - assert has_element?(view, "#dashboard-run-governance-#{run_dom_token}", "Evidence: 3") - assert has_element?(view, "#dashboard-run-governance-#{run_dom_token}", "Review: open") - end - - test "renders bounded conversation supervision summaries that route back to repo detail", %{ - conn: _conn - } do - register_owner("conversation-dashboard-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("conversation-dashboard-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :dashboard_conversation_summary_loader, fn -> - {:ok, - [ - %{ - id: "conversation-summary-1", - route_id: "repo-conversation-dashboard", - managed_repo_id: Ecto.UUID.generate(), - repo_label: "owner/repo-conversation-dashboard", - role_scope: "work_item_scoped", - role_attachment_mode: "governed_work", - role_work_item_id: "work-item-1", - latest_status: "active", - active_count: 2, - clarification_count: 1, - detail: - "2 governed conversations active. 1 clarification turn needs an answer. Latest governed work: Review queued operator request.", - latest_work_item_summary: "Review queued operator request.", - latest_activity_at: ~U[2026-04-24 14:05:00Z], - route: "/repos/repo-conversation-dashboard#project-detail-conversation-panel", - action_label: "Open governed supervision" - } - ], nil} - end) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/dashboard?subject=work§ion=conversations", - on_error: :warn - ) - - assert has_element?(view, "#dashboard-conversation-supervision") - assert has_element?(view, "#dashboard-conversation-summary-note", "route back to canonical repo detail") - - assert has_element?( - view, - "#dashboard-conversation-summary-repo-conversation-summary-1", - "owner/repo-conversation-dashboard" - ) - - assert has_element?( - view, - "#dashboard-conversation-summary-role-conversation-summary-1", - "Governed conversation" - ) - - assert has_element?( - view, - "#dashboard-conversation-summary-status-conversation-summary-1", - "active" - ) - - assert has_element?( - view, - "#dashboard-conversation-summary-clarification-conversation-summary-1", - "1 clarification needed" - ) - - assert has_element?( - view, - "#dashboard-conversation-summary-counts-conversation-summary-1", - "Active governed conversations: 2" - ) - - assert has_element?( - view, - "#dashboard-conversation-summary-link-conversation-summary-1[href='/repos/repo-conversation-dashboard#project-detail-conversation-panel']", - "Open governed supervision" - ) - end - - test "renders runtime posture summaries in product-oriented rollout language", %{conn: _conn} do - register_owner("runtime-dashboard-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("runtime-dashboard-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :dashboard_runtime_evidence_loader, fn -> - {:ok, - [ - %{ - id: "runtime-posture-1", - managed_repo_id: Ecto.UUID.generate(), - repo_label: "repo-runtime-dashboard", - status: "degraded", - summary: - "Runtime service evidence indicates degraded execution trust. Coding turn delivery repaired through replay recovery.", - delivery_mode: "replay_recovery", - reason_code: "live_delivery_detached", - latest_provider: "github", - supervision_mode: "guided", - review_required: true, - updated_at: DateTime.utc_now() - } - ], nil} - end) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/dashboard?subject=runtime§ion=runtime", - on_error: :warn - ) - - assert has_element?(view, "#dashboard-runtime-evidence") - assert has_element?(view, "#dashboard-runtime-evidence-fallback") - assert has_element?(view, "#dashboard-runtime-evidence-summary", "review-required") - assert has_element?(view, "#dashboard-runtime-evidence-note", "source of truth") - assert has_element?(view, "#dashboard-runtime-evidence-count-degraded", "1") - assert has_element?(view, "#dashboard-runtime-evidence-runtime-posture-1", "repo-runtime-dashboard") - - vue = assert_vue_component(view, "DashboardRuntimePostureWidget", id: "dashboard-runtime-posture-widget") - - assert vue.props["counts"] == %{"available" => 0, "blocked" => 0, "degraded" => 1} - - assert Enum.any?(vue.props["runtimeEvidenceSummaries"], fn runtime_summary -> - runtime_summary["repoLabel"] == "repo-runtime-dashboard" and - runtime_summary["status"] == "degraded" - end) - - assert has_element?( - view, - "#dashboard-runtime-evidence-item-summary-runtime-posture-1", - "degraded execution trust" - ) - - assert has_element?( - view, - "#dashboard-runtime-evidence-item-details-runtime-posture-1", - "Delivery: replay recovery" - ) - - assert has_element?( - view, - "#dashboard-runtime-evidence-item-details-runtime-posture-1", - "Latest provider: github" - ) - end - - test "renders bounded repository memory summaries with canonical follow-up routes", %{conn: _conn} do - register_owner("memory-dashboard-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-dashboard-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :dashboard_memory_summary_loader, fn -> - {:ok, - [ - %{ - id: "memory-summary-1", - route_id: "repo-memory-dashboard", - managed_repo_id: Ecto.UUID.generate(), - repo_label: "owner/repo-memory-dashboard", - state: "invalidated", - label: "Memory graph invalidated", - detail: "Repository memory graph validation was explicitly invalidated and should be revalidated.", - remediation: "Validate repository memory graph for the current revision.", - memory_count: 3, - provenance_count: 2, - route: "/repos/repo-memory-dashboard#project-detail-memory-inspection", - action_label: "Validate memory graph", - action_needed?: true, - recovery_available?: true, - latest_revision: "abc123" - } - ], nil} - end) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/dashboard?subject=knowledge§ion=memory", - on_error: :warn - ) - - assert has_element?(view, "#dashboard-memory-summaries") - assert has_element?(view, "#dashboard-memory-summary-note", "canonical managed-repository surfaces") - assert has_element?(view, "#dashboard-memory-summary-repo-memory-summary-1", "owner/repo-memory-dashboard") - assert has_element?(view, "#dashboard-memory-summary-state-memory-summary-1", "Memory graph invalidated") - assert has_element?(view, "#dashboard-memory-summary-counts-memory-summary-1", "Durable memory: 3") - assert has_element?(view, "#dashboard-memory-summary-counts-memory-summary-1", "Workflow provenance: 2") - assert has_element?(view, "#dashboard-memory-summary-action-needed-memory-summary-1", "action needed") - - assert has_element?( - view, - "#dashboard-memory-summary-link-memory-summary-1[href=\"/repos/repo-memory-dashboard#project-detail-memory-inspection\"]", - "Validate memory graph" - ) - end - - defp create_run(repo_identifier, run_id, started_at, attrs \\ %{}) do - {:ok, repo_scope} = RepoBridge.repo_scope(repo_identifier) - - {:ok, project} = - Project.create( - %{ - name: "dashboard-#{run_id}", - source_kind: :local, - local_path: "/tmp/dashboard-#{run_id}" - }, - actor: Actor.operator_actor() - ) - - workflow_attrs = - %{ - project_id: project.id, - managed_repo_id: repo_scope.managed_repo.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render dashboard run summaries"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: started_at - } - - {:ok, workflow_run} = WorkflowRun.create(workflow_attrs, actor: Actor.operator_actor()) - - final_workflow_run = - workflow_run - |> maybe_transition_to_running(attrs, started_at) - |> maybe_transition_to_terminal(attrs, started_at) - - Run.get_by_workflow_run_id(final_workflow_run.id, actor: Actor.operator_actor()) - end - - defp maybe_transition_to_running(workflow_run, attrs, started_at) do - desired_status = Map.get(attrs, :status, :pending) - - if desired_status in [:running, :awaiting_approval, :completed, :failed, :cancelled] do - {:ok, workflow_run} = - WorkflowRun.transition_status( - workflow_run, - %{ - to_status: :running, - current_step: Map.get(attrs, :current_step, "queued"), - transitioned_at: DateTime.add(started_at, 1, :second) - }, - actor: Actor.operator_actor() - ) - - workflow_run - else - workflow_run - end - end - - defp maybe_transition_to_terminal(%WorkflowRun{} = workflow_run, attrs, started_at) do - case Map.get(attrs, :status, :pending) do - status when status in [:awaiting_approval, :completed, :failed, :cancelled] -> - {:ok, workflow_run} = - WorkflowRun.transition_status( - workflow_run, - %{ - to_status: status, - current_step: Map.get(attrs, :current_step, workflow_run.current_step), - transitioned_at: Map.get(attrs, :completed_at) || DateTime.add(started_at, 60, :second) - }, - actor: Actor.operator_actor() - ) - - workflow_run - - _other -> - workflow_run - end - end - - defp run_dom_token(value) do - value - |> to_string() - |> String.replace(~r/[^a-zA-Z0-9_-]/, "-") - end - - defp rendered_fragment_index(html, fragment) when is_binary(html) and is_binary(fragment) do - case :binary.match(html, fragment) do - {index, _length} -> index - :nomatch -> flunk("expected fragment #{inspect(fragment)} in rendered output") - end - end - - defp assert_eventually(assertion_fun, attempts \\ 20) - - defp assert_eventually(assertion_fun, attempts) when attempts > 0 do - if assertion_fun.() do - :ok - else - receive do - after - 25 -> - assert_eventually(assertion_fun, attempts - 1) - end - end - end - - defp assert_eventually(_assertion_fun, 0) do - flunk("expected condition to become true") - end -end diff --git a/test/jido_code_web/live/frontend_reset_live_test.exs b/test/jido_code_web/live/frontend_reset_live_test.exs new file mode 100644 index 00000000..af2555da --- /dev/null +++ b/test/jido_code_web/live/frontend_reset_live_test.exs @@ -0,0 +1,42 @@ +defmodule JidoCodeWeb.FrontendResetLiveTest do + use JidoCodeWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + test "public route renders the reset frontend surface", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/welcome") + + assert html =~ ~s|id="jido-code-reset-surface"| + assert html =~ "Welcome" + assert html =~ "previous UI component" + end + + test "authenticated product routes render reset surfaces", %{conn: _conn} do + conn = authenticated_owner_conn() + + for {path, title} <- [ + {~p"/dashboard", "Dashboard"}, + {~p"/workbench", "Workbench"}, + {~p"/workflows", "Workflows"}, + {~p"/agents", "Agents"}, + {~p"/repos", "Repositories"}, + {~p"/settings", "Settings"} + ] do + {:ok, _view, html} = live(recycle(conn), path) + + assert html =~ ~s|id="jido-code-reset-surface"| + assert html =~ title + end + end + + defp authenticated_owner_conn do + unique_suffix = System.unique_integer([:positive]) + email = "frontend-reset-owner-#{unique_suffix}@example.com" + password = "owner-password-123" + + register_owner(email, password) + + build_conn() + |> authenticate_owner_conn(email, password) + end +end diff --git a/test/jido_code_web/live/home_live_operator_settings_test.exs b/test/jido_code_web/live/home_live_operator_settings_test.exs deleted file mode 100644 index 7af20baf..00000000 --- a/test/jido_code_web/live/home_live_operator_settings_test.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule JidoCodeWeb.HomeLiveOperatorSettingsTest do - # covers: auth.operator_settings.hidden_during_bootstrap_entry - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - test "signed-in welcome keeps auth and integration management out of the landing surface", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/welcome") - - assert has_element?(view, ~s|a[href="/settings/auth"]#welcome-open-settings|, "Open Auth & Integrations") - refute has_element?(view, "#provider-login-settings") - refute has_element?(view, "#git-provider-integrations") - refute has_element?(view, "#refresh-github-service-checks") - refute has_element?(view, "#welcome-operator-settings-handoff") - end -end diff --git a/test/jido_code_web/live/home_live_test.exs b/test/jido_code_web/live/home_live_test.exs deleted file mode 100644 index 7fd2496e..00000000 --- a/test/jido_code_web/live/home_live_test.exs +++ /dev/null @@ -1,159 +0,0 @@ -defmodule JidoCodeWeb.HomeLiveTest do - # covers: baseline.surface.auth_entrypoints_visible - # covers: baseline.surface.welcome_landing_copy - # covers: setup.onboarding.deployment_mode_auto_detected - # covers: auth.provider_login_flow.entrypoint_visible - # covers: auth.provider_login_flow.local_auth_fallback_visible - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AuthProviders.ProviderConfig - - setup do - original_target = System.get_env("BURRITO_TARGET") - original_prerequisite_checker = Application.get_env(:jido_code, :setup_prerequisite_checker, :__missing__) - - original_prerequisite_timeout_ms = - Application.get_env(:jido_code, :setup_prerequisite_timeout_ms, :__missing__) - - on_exit(fn -> - case original_target do - nil -> System.delete_env("BURRITO_TARGET") - value -> System.put_env("BURRITO_TARGET", value) - end - - case original_prerequisite_checker do - :__missing__ -> Application.delete_env(:jido_code, :setup_prerequisite_checker) - value -> Application.put_env(:jido_code, :setup_prerequisite_checker, value) - end - - case original_prerequisite_timeout_ms do - :__missing__ -> Application.delete_env(:jido_code, :setup_prerequisite_timeout_ms) - value -> Application.put_env(:jido_code, :setup_prerequisite_timeout_ms, value) - end - end) - - :ok - end - - test "landing page opens first-run bootstrap when no users exist", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/welcome") - - assert html =~ "Create your admin account" - assert html =~ "Checking your system" - refute html =~ "Sign In with GitHub" - end - - test "landing page hides provider login during bootstrap even when GitHub login is configured", %{conn: conn} do - enable_provider_login!(:github, "github.com") - - {:ok, _view, html} = live(conn, ~p"/welcome") - - refute html =~ "Sign In with GitHub" - refute html =~ "Create Account" - end - - test "landing page uses desktop bootstrap copy when deployment mode auto-detects desktop", %{conn: conn} do - System.put_env("BURRITO_TARGET", "darwin-aarch64") - - {:ok, _view, html} = live(conn, ~p"/welcome") - - assert html =~ "brand-new desktop install" - end - - test "landing page settles to a timeout state when prerequisite checking hangs", %{conn: conn} do - Application.put_env(:jido_code, :setup_prerequisite_timeout_ms, 25) - - Application.put_env(:jido_code, :setup_prerequisite_checker, fn _timeout_ms -> - Process.sleep(200) - - %{ - checked_at: DateTime.utc_now() |> DateTime.truncate(:second), - status: :pass, - checks: [] - } - end) - - {:ok, view, _html} = live(conn, ~p"/welcome") - - assert_eventually(fn -> - rendered = render(view) - - assert rendered =~ "Some checks timed out. Your system may not be fully ready." - assert rendered =~ "Complete system check first" - refute rendered =~ "Checking your system…" - end) - end - - test "landing page exposes sign-in and GitHub login once a local user already exists", %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - enable_provider_login!(:github, "github.com") - - {:ok, view, _html} = live(conn, ~p"/welcome") - - assert has_element?( - view, - ~s|a[href="/auth/providers/github/start?provider_host=github.com"]|, - "Sign In with GitHub" - ) - - assert has_element?(view, "a", "Sign In") - refute has_element?(view, "a", "Create Account") - end - - test "signed-in ready-state welcome stays a compact dashboard and settings handoff", - %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = authenticate_owner_conn("owner@example.com", "owner-password-123") - - {:ok, view, html} = live(recycle(authed_conn), ~p"/welcome") - - assert has_element?(view, "#welcome-open-dashboard", "Open Dashboard") - assert has_element?(view, "#welcome-open-settings", "Open Auth & Integrations") - - assert has_element?( - view, - "#welcome-ready-handoff-note", - "Dashboard is the default authenticated entry" - ) - - refute has_element?(view, "#welcome-operator-settings-handoff") - - refute html =~ - "Product routes, demos, setup flows, APIs, and workbench surfaces are commented out until the new baseline is validated." - end - - defp enable_provider_login!(provider, provider_host) do - {:ok, config} = - ProviderConfig.upsert( - %{ - provider: provider, - provider_host: provider_host, - enabled: true, - login_enabled: true, - broker_issuer: "https://broker.example.com", - broker_audience: "jido-code", - broker_base_url: "https://broker.example.com" - }, - authorize?: false - ) - - config - end - - defp assert_eventually(assertion_fun, attempts \\ 20) - - defp assert_eventually(assertion_fun, attempts) when attempts > 0 do - assertion_fun.() - rescue - error in [ExUnit.AssertionError] -> - if attempts == 1 do - reraise error, __STACKTRACE__ - else - Process.sleep(25) - assert_eventually(assertion_fun, attempts - 1) - end - end -end diff --git a/test/jido_code_web/live/operator_navigation_live_test.exs b/test/jido_code_web/live/operator_navigation_live_test.exs deleted file mode 100644 index f55600c7..00000000 --- a/test/jido_code_web/live/operator_navigation_live_test.exs +++ /dev/null @@ -1,274 +0,0 @@ -defmodule JidoCodeWeb.OperatorNavigationLiveTest do - # covers: architecture.operator_surface_information_architecture.signed_in_routes_share_global_wayfinding - # covers: architecture.operator_surface_information_architecture.global_wayfinding_uses_shared_liveview_helpers - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Orchestration.WorkflowRun - alias JidoCode.Projects.Project - - test "shared operator navigation renders consistent selected states and context across major signed-in routes", - %{conn: _conn} do - register_owner("operator-nav-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("operator-nav-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-operator-navigation", - github_full_name: "owner/repo-operator-navigation", - default_branch: "main", - settings: %{} - }) - - run_id = "run-operator-navigation-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Verify global navigation route coverage"}, - input_metadata: %{"task_summary" => %{required: true, source: "test"}}, - initiating_actor: %{id: "owner-1", email: "operator-nav-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-28 12:00:00Z] - }) - - {:ok, dashboard_view, _html} = - live(recycle(authed_conn), ~p"/dashboard?subject=work§ion=overview", on_error: :warn) - - assert has_element?(dashboard_view, "#operator-global-nav-dashboard[aria-current='page']") - assert has_element?(dashboard_view, "#operator-global-nav-workbench") - assert has_element?(dashboard_view, "#operator-global-nav-repositories") - assert has_element?(dashboard_view, "#operator-route-badge", "Authenticated home") - assert has_element?(dashboard_view, "#operator-route-label", "Dashboard") - refute has_element?(dashboard_view, "#operator-context-nav") - - {:ok, workbench_view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(workbench_view, "#operator-global-nav-workbench[aria-current='page']") - assert has_element?(workbench_view, "#operator-route-badge", "Specialist inventory") - assert has_element?(workbench_view, "#operator-route-label", "Workbench") - refute has_element?(workbench_view, "#operator-context-nav") - - {:ok, settings_view, _html} = live(recycle(authed_conn), ~p"/settings/auth", on_error: :warn) - - assert has_element?(settings_view, "#operator-global-nav-settings[aria-current='page']") - assert has_element?(settings_view, "#operator-route-badge", "Operator configuration") - assert has_element?(settings_view, "#operator-route-label", "Settings") - assert has_element?(settings_view, "#operator-context-settings-tab[aria-current='page']", "Auth & Integrations") - - {:ok, repo_view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - assert has_element?(repo_view, "#operator-global-nav-repositories[aria-current='page']") - assert has_element?(repo_view, "#operator-route-badge", "Managed repo") - assert has_element?(repo_view, "#operator-route-label", "owner/repo-operator-navigation") - assert has_element?(repo_view, "#operator-context-repo[aria-current='page']", "owner/repo-operator-navigation") - - {:ok, run_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(run_view, "#operator-global-nav-repositories[aria-current='page']") - assert has_element?(run_view, "#operator-route-badge", "Governed run") - assert has_element?(run_view, "#operator-route-label", "Run #{run_id}") - - assert has_element?( - run_view, - "#operator-context-repo[href='/repos/#{project.id}?return_to=#{URI.encode_www_form("/repos")}']" - ) - - assert has_element?(run_view, "#operator-context-run[aria-current='page']", "Run #{run_id}") - end - - test "repo and run detail preserve the broader selected major destination from explicit parent surfaces", - %{conn: _conn} do - register_owner("operator-nav-parent-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("operator-nav-parent-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-operator-parent", - github_full_name: "owner/repo-operator-parent", - default_branch: "main", - settings: %{} - }) - - run_id = "run-operator-parent-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Verify parent-surface selection"}, - input_metadata: %{"task_summary" => %{required: true, source: "test"}}, - initiating_actor: %{id: "owner-1", email: "operator-nav-parent-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-28 12:30:00Z] - }) - - dashboard_return_to = "/dashboard?subject=work§ion=overview" - encoded_dashboard_return_to = URI.encode_www_form(dashboard_return_to) - - {:ok, dashboard_repo_view, _html} = - live( - recycle(authed_conn), - "/repos/#{project.id}?return_to=#{encoded_dashboard_return_to}", - on_error: :warn - ) - - assert has_element?(dashboard_repo_view, "#operator-global-nav-dashboard[aria-current='page']") - - assert has_element?( - dashboard_repo_view, - "#project-detail-breadcrumb-return[href='#{dashboard_return_to}']", - "Dashboard" - ) - - workbench_return_to = "/workbench" - encoded_workbench_return_to = URI.encode_www_form(workbench_return_to) - - {:ok, workbench_repo_view, _html} = - live( - recycle(authed_conn), - "/repos/#{project.id}?return_to=#{encoded_workbench_return_to}", - on_error: :warn - ) - - assert has_element?(workbench_repo_view, "#operator-global-nav-workbench[aria-current='page']") - assert has_element?(workbench_repo_view, "#project-detail-breadcrumb-return[href='/workbench']", "Workbench") - - {:ok, workbench_run_view, _html} = - live( - recycle(authed_conn), - "/repos/#{project.id}/runs/#{run_id}?return_to=#{encoded_workbench_return_to}", - on_error: :warn - ) - - assert has_element?(workbench_run_view, "#operator-global-nav-workbench[aria-current='page']") - - assert has_element?( - workbench_run_view, - "#operator-context-repo[href='/repos/#{project.id}?return_to=#{encoded_workbench_return_to}']" - ) - end - - test "adjacent signed-in routes reuse proportional shells and keep route-owned local navigation", - %{conn: _conn} do - register_owner("operator-nav-adjacent-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("operator-nav-adjacent-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-operator-adjacent", - github_full_name: "owner/repo-operator-adjacent", - default_branch: "main", - settings: %{} - }) - - run_id = "run-operator-adjacent-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Verify adjacent route shell contracts"}, - input_metadata: %{"task_summary" => %{required: true, source: "test"}}, - initiating_actor: %{id: "owner-1", email: "operator-nav-adjacent-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-28 13:00:00Z] - }) - - {:ok, workbench_view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert_single_pane_shell(workbench_view, "workbench-shell", "workbench-pane") - assert has_element?(workbench_view, "#workbench-return-dashboard") - assert has_element?(workbench_view, "#workbench-apply-filters[form='workbench-filters-form']") - refute has_element?(workbench_view, "#workbench-nav") - - {:ok, inventory_view, _html} = live(recycle(authed_conn), ~p"/repos", on_error: :warn) - - assert_single_pane_shell(inventory_view, "project-inventory-shell", "project-inventory-pane") - assert has_element?(inventory_view, "#project-inventory-reset-filters[href='/repos']") - - assert has_element?( - inventory_view, - "#project-inventory-apply-filters[form='project-inventory-filters-form']" - ) - - refute has_element?(inventory_view, "#project-inventory-nav") - - {:ok, workflows_view, _html} = live(recycle(authed_conn), ~p"/workflows", on_error: :warn) - - assert_single_pane_shell(workflows_view, "workflows-shell", "workflows-pane") - assert has_element?(workflows_view, "#workflows-start-run") - refute has_element?(workflows_view, "#workflows-nav") - - {:ok, agents_view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - assert_single_pane_shell(agents_view, "agents-shell", "agents-pane") - assert has_element?(agents_view, "#agents-project-table") - refute has_element?(agents_view, "#agents-nav") - - {:ok, settings_view, _html} = live(recycle(authed_conn), ~p"/settings/auth", on_error: :warn) - - assert has_element?(settings_view, "#settings-shell") - assert has_element?(settings_view, "#settings-shell-breadcrumbs") - refute has_element?(settings_view, "#settings-shell-parent-subjects") - assert has_element?(settings_view, "#settings-nav-auth[aria-current='page']", "Auth & Integrations") - assert has_element?(settings_view, "#settings-pane-auth") - assert has_element?(settings_view, "#settings-pane-auth-middle") - assert has_element?(settings_view, "#settings-pane-auth-footer") - assert has_element?(settings_view, "#settings-auth-refresh-github-service-checks") - assert has_element?(settings_view, "#operator-context-settings-tab[aria-current='page']", "Auth & Integrations") - - settings_view - |> element("#settings-nav-security") - |> render_click() - - assert_patch(settings_view, "/settings/security") - assert has_element?(settings_view, "#settings-nav-security[aria-current='page']", "Security") - assert has_element?(settings_view, "#settings-pane-security") - assert has_element?(settings_view, "#settings-pane-security-middle") - refute has_element?(settings_view, "#settings-pane-security-footer") - refute has_element?(settings_view, "#settings-auth-refresh-github-service-checks") - assert has_element?(settings_view, "#operator-context-settings-tab[aria-current='page']", "Security settings") - - {:ok, run_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert_single_pane_shell(run_view, "run-detail-shell", "run-detail") - assert has_element?(run_view, "#run-detail-breadcrumb-parent[href='/repos']", "Repositories") - - assert has_element?( - run_view, - "#run-detail-breadcrumb-repo[href='/repos/#{project.id}?return_to=#{URI.encode_www_form("/repos")}']" - ) - - assert has_element?(run_view, "#run-detail-breadcrumb-current", "Run #{run_id}") - assert has_element?(run_view, "#run-detail-return-link[href='/repos/#{project.id}']", "Back to Repo detail") - end - - defp assert_single_pane_shell(view, shell_id, pane_id) do - assert has_element?(view, "##{shell_id}") - assert has_element?(view, "##{shell_id}-breadcrumbs") - refute has_element?(view, "##{shell_id}-parent-subjects") - assert has_element?(view, "##{pane_id}") - assert has_element?(view, "##{pane_id}-header") - assert has_element?(view, "##{pane_id}-middle") - end -end diff --git a/test/jido_code_web/live/phase_eighteen_integration_test.exs b/test/jido_code_web/live/phase_eighteen_integration_test.exs deleted file mode 100644 index b60e50b0..00000000 --- a/test/jido_code_web/live/phase_eighteen_integration_test.exs +++ /dev/null @@ -1,62 +0,0 @@ -defmodule JidoCodeWeb.PhaseEighteenIntegrationTest do - # covers: architecture.run_governance.execution_projection_stays_internal_to_canonical_run_model - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.Actor - alias JidoCode.Orchestration.{Run, WorkflowRun} - alias JidoCode.Projects.Project - - test "run detail requires a governed run projection and does not fall back to workflow history", - %{conn: _conn} do - register_owner("phase-eighteen-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase-eighteen-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "phase-eighteen-run-detail", - github_full_name: "owner/phase-eighteen-run-detail", - default_branch: "main", - settings: %{} - }) - - run_id = "phase-eighteen-run-detail-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{"source" => "phase_eighteen_test", "mode" => "manual"}, - inputs: %{"task_summary" => "Run detail canonical projection"}, - input_metadata: %{"task_summary" => %{"required" => true, "source" => "test"}}, - initiating_actor: %{id: "owner-1", email: "phase-eighteen-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-04 11:00:00Z] - }) - - {:ok, _view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - {:ok, run} = Run.get_by_workflow_run_id(workflow_run.id, actor: Actor.operator_actor()) - :ok = Ash.destroy(run, actor: Actor.factory_system_actor()) - - {:ok, missing_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(missing_view, "#run-detail-missing-title", "Run not found") - assert has_element?(missing_view, "#run-detail-missing-detail", run_id) - - {:ok, persisted_workflow_run} = - WorkflowRun.get_by_project_and_run_id( - %{project_id: project.id, run_id: run_id}, - actor: Actor.operator_actor() - ) - - assert persisted_workflow_run.id == workflow_run.id - end -end diff --git a/test/jido_code_web/live/phase_eleven_integration_test.exs b/test/jido_code_web/live/phase_eleven_integration_test.exs deleted file mode 100644 index 84f0e56d..00000000 --- a/test/jido_code_web/live/phase_eleven_integration_test.exs +++ /dev/null @@ -1,177 +0,0 @@ -defmodule JidoCodeWeb.PhaseElevenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - # covers: architecture.runtime_service_overlay.runtime_topology_details_remain_opaque_to_product - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Governance.RepoPosture - alias JidoCode.Orchestration.WorkflowRun - alias JidoCode.Projects.Project - - test "dashboard and run detail stay review-safe when runtime rollout is blocked", %{conn: _conn} do - register_owner("phase-eleven-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase-eleven-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-phase-eleven-live", - github_full_name: "owner/repo-phase-eleven-live", - default_branch: "main", - settings: %{} - }) - - run_id = "run-phase-eleven-live-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Governed review remains available."}, - input_metadata: %{"task_summary" => %{required: true, source: "test"}}, - initiating_actor: %{id: "owner-1", email: "phase-eleven-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-02 12:00:00Z], - step_results: %{ - "diff_summary" => "1 file changed (+6/-0).", - "runtime_service_delivery" => %{ - "delivery_mode" => "replay_fallback", - "reason_code" => "rollout_withheld", - "terminal_handoff_kind" => "replay_terminal_lookup", - "terminal_state" => "completed", - "summary" => "Runtime delivery fell back to replay because rollout was withheld." - } - } - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-02 12:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-02 12:02:00Z] - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, repo_posture} = - RepoPosture.upsert_for_managed_repo( - %{ - managed_repo_id: managed_repo.id, - summary: "Repo posture remains governed.", - overall_trust: "medium", - execution_readiness: "low", - validation_reliability: "high", - review_burden: "high", - drift_rate: "low", - recovery_resilience: "medium", - requirements_confidence: "high", - supervision_mode: "guided", - escalation_status: "review", - contributing_check_ids: [], - posture_metadata: %{ - "runtime_service_evidence_summary" => - "Runtime rollout was withheld, but governed review remains available through bounded fallback evidence.", - "runtime_service_evidence_state" => %{ - "status" => "blocked", - "review_required" => true, - "runtime_delivery" => %{ - "delivery_mode" => "replay_fallback", - "reason_code" => "rollout_withheld" - }, - "integration_outcomes" => %{ - "latest_invocation" => %{ - "provider" => "github", - "summary" => "github review handoff remains available" - } - } - } - } - }, - actor: Actor.operator_actor() - ) - - {:ok, dashboard_view, dashboard_html} = - live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - - assert has_element?(dashboard_view, "#dashboard-runtime-evidence-count-blocked", "1") - - assert has_element?( - dashboard_view, - "#dashboard-runtime-evidence-summary", - "Runtime posture tracks 1 repo(s): 1 blocked" - ) - - assert has_element?( - dashboard_view, - "#dashboard-runtime-evidence-note", - "Product records remain the source of truth" - ) - - assert has_element?( - dashboard_view, - "#dashboard-runtime-evidence-item-details-#{dom_token(repo_posture.id)}", - "Delivery: replay fallback" - ) - - assert has_element?( - dashboard_view, - "#dashboard-runtime-evidence-item-details-#{dom_token(repo_posture.id)}", - "Latest provider: github" - ) - - refute dashboard_html =~ "GlobalSignalBus" - refute dashboard_html =~ "subscriber_ref" - - {:ok, run_view, run_html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(run_view, "#run-detail-governance-summary") - assert has_element?(run_view, "#run-detail-runtime-evidence-status", "blocked") - - assert has_element?( - run_view, - "#run-detail-runtime-evidence-summary", - "governed review remains available" - ) - - assert has_element?( - run_view, - "#run-detail-runtime-evidence-delivery-mode", - "replay fallback" - ) - - assert has_element?(run_view, "#run-detail-runtime-evidence-reason", "rollout withheld") - - assert has_element?( - run_view, - "#run-detail-runtime-evidence-note", - "Product governance stores bounded runtime evidence" - ) - - refute run_html =~ "GlobalSignalBus" - refute run_html =~ "subscriber_ref" - end - - defp dom_token(value) do - value - |> to_string() - |> String.replace(~r/[^a-zA-Z0-9_-]/, "-") - end -end diff --git a/test/jido_code_web/live/phase_fifteen_integration_test.exs b/test/jido_code_web/live/phase_fifteen_integration_test.exs deleted file mode 100644 index 813461e5..00000000 --- a/test/jido_code_web/live/phase_fifteen_integration_test.exs +++ /dev/null @@ -1,109 +0,0 @@ -defmodule JidoCodeWeb.PhaseFifteenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - # covers: architecture.frontend_stack.frontend_bridge_observability_stays_product_oriented - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - setup do - original_override = Application.get_env(:jido_code, :frontend_assets_override, :__missing__) - - on_exit(fn -> - restore_env(:frontend_assets_override, original_override) - end) - - :ok - end - - test "dashboard falls back to bounded liveview fallback when richer delivery degrades", %{ - conn: _conn - } do - register_owner("phase15-dashboard-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase15-dashboard-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - {:ok, view, html} = live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - - assert has_element?( - view, - "#dashboard-run-summary-widget-fallback", - "Interactive summary temporarily unavailable" - ) - - assert has_element?( - view, - "#dashboard-run-summary-widget-fallback", - "server-rendered fallback mode" - ) - - assert has_element?( - view, - "#dashboard-run-summary-widget-fallback", - "Fallback mode reason: Asset manifest unavailable" - ) - - assert has_element?(view, "#dashboard-run-summary-fallback", "LiveView detail fallback") - - assert has_element?( - view, - "#dashboard-runtime-posture-widget-fallback", - "Interactive summary temporarily unavailable" - ) - - assert has_element?(view, "#dashboard-runtime-evidence-fallback", "LiveView posture details") - refute html =~ "actions/setup-node" - refute html =~ "NodeJS.Supervisor" - refute html =~ "vite build" - end - - test "plain liveview routes stay unaffected by frontend fallback delivery", %{conn: conn} do - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - {:ok, _view, html} = live(conn, ~p"/welcome") - - assert html =~ "Create your admin account" - assert html =~ "Checking your system" - refute html =~ "Interactive summary temporarily unavailable" - refute html =~ "Fallback mode reason:" - end - - test "repo docs and ci converge on the live vue verification path" do - readme = repo_file!("README.md") - contributing = repo_file!("CONTRIBUTING.md") - agents = repo_file!("AGENTS.md") - ci_workflow = repo_file!(".github/workflows/ci.yml") - mixfile = repo_file!("mix.exs") - phase_plan = repo_file!(".planning/phase-15-frontend-rollout-hardening-and-contributor-convergence.md") - - assert readme =~ "mix frontend.verify" - assert readme =~ "<.vue_surface" - assert contributing =~ "mix frontend.verify" - assert contributing =~ "LiveView-first" - assert agents =~ "mix frontend.verify" - assert agents =~ "<.vue_surface ...>" - assert mixfile =~ "\"frontend.verify\": [\"assets.setup\", \"assets.build\"]" - assert ci_workflow =~ "Verify frontend asset pipeline" - assert ci_workflow =~ "actions/setup-node@v4" - assert phase_plan =~ "[x] 15 Phase 15 - Frontend Rollout Hardening and Contributor Convergence" - assert phase_plan =~ "[x] 15.3 Section - Phase 15 Integration Tests" - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_fifty_eight_integration_test.exs b/test/jido_code_web/live/phase_fifty_eight_integration_test.exs deleted file mode 100644 index e3e00f4b..00000000 --- a/test/jido_code_web/live/phase_fifty_eight_integration_test.exs +++ /dev/null @@ -1,254 +0,0 @@ -defmodule JidoCodeWeb.PhaseFiftyEightIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: baseline.surface.public_entry_routes - # covers: baseline.surface.welcome_landing_copy - # covers: baseline.surface.root_redirects_to_welcome - # covers: auth.system.ready_state_local_auth_handoff - # covers: auth.operator_settings.hidden_during_bootstrap_entry - # covers: setup.onboarding.post_bootstrap_start_surface - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias AshAuthentication.{Info, Strategy} - alias JidoCode.Accounts.User - - setup do - original_config = Application.get_env(:jido_code, :system_config, :__missing__) - original_loader = Application.get_env(:jido_code, :system_config_loader, :__missing__) - - on_exit(fn -> - restore_env(:system_config, original_config) - restore_env(:system_config_loader, original_loader) - end) - - Application.delete_env(:jido_code, :system_config_loader) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 1, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }) - - :ok - end - - test "58.3.1 bootstrap-required and continue-setup routes keep welcome public and setup gated", %{ - conn: conn - } do - root_response = get(conn, ~p"/") - assert redirected_to(root_response, 302) == "/welcome" - - {:ok, bootstrap_view, bootstrap_html} = live(conn, ~p"/welcome", on_error: :warn) - - assert bootstrap_html =~ "Create your admin account" - refute has_element?(bootstrap_view, "#provider-login-settings") - assert_live_redirect(live(conn, ~p"/dashboard", on_error: :warn), "/welcome") - - register_owner("phase58-continue-owner@example.com", "owner-password-123") - Application.put_env(:jido_code, :system_config, continue_setup_config("phase58-continue-owner@example.com")) - - {:ok, sign_in_view, _html} = live(conn, ~p"/sign-in", on_error: :warn) - - sign_in_view - |> form("form[action='/auth/user/password/sign_in']", %{ - "user" => %{ - "email" => "phase58-continue-owner@example.com", - "password" => "owner-password-123" - } - }) - |> render_submit() - - auth_redirect_path = - sign_in_view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/setup" - - {authed_conn, _session_token} = - authenticate_owner_conn("phase58-continue-owner@example.com", "owner-password-123") - - assert_live_redirect(live(recycle(authed_conn), ~p"/dashboard", on_error: :warn), "/setup") - assert_live_redirect(live(recycle(authed_conn), ~p"/welcome", on_error: :warn), "/setup") - end - - test "58.3.2 ready-state auth hands off to dashboard while sign-out returns to public welcome", %{ - conn: conn - } do - register_owner("phase58-ready-owner@example.com", "owner-password-123") - Application.put_env(:jido_code, :system_config, ready_config()) - - root_response = get(conn, ~p"/") - assert redirected_to(root_response, 302) == "/welcome" - - {:ok, sign_in_view, _html} = live(conn, ~p"/sign-in", on_error: :warn) - - sign_in_view - |> form("form[action='/auth/user/password/sign_in']", %{ - "user" => %{ - "email" => "phase58-ready-owner@example.com", - "password" => "owner-password-123" - } - }) - |> render_submit() - - auth_redirect_path = - sign_in_view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/dashboard" - - strategy = Info.strategy!(User, :password) - - {:ok, owner} = - Strategy.action( - strategy, - :sign_in, - %{ - "email" => "phase58-ready-owner@example.com", - "password" => "owner-password-123" - }, - context: %{token_type: :sign_in} - ) - - token = - owner - |> Map.get(:__metadata__, %{}) - |> Map.fetch!(:token) - - return_to_response = - conn - |> init_test_session(%{}) - |> put_session(:return_to, "/settings") - |> get(owner_sign_in_with_token_path(strategy, token)) - - assert redirected_to(return_to_response, 302) == "/settings" - - {authed_conn, _session_token} = - authenticate_owner_conn("phase58-ready-owner@example.com", "owner-password-123") - - {:ok, welcome_view, welcome_html} = - live(recycle(authed_conn), ~p"/welcome", on_error: :warn) - - assert has_element?(welcome_view, "#welcome-open-dashboard", "Open Dashboard") - assert has_element?(welcome_view, "#welcome-open-settings", "Open Auth & Integrations") - refute has_element?(welcome_view, "#welcome-operator-settings-handoff") - refute has_element?(welcome_view, "#provider-login-settings") - - refute welcome_html =~ - "Product routes, demos, setup flows, APIs, and workbench surfaces are commented out until the new baseline is validated." - - sign_out_response = get(authed_conn, ~p"/sign-out") - - assert redirected_to(sign_out_response, 302) == "/" - assert redirected_to(sign_out_response |> recycle() |> get(~p"/"), 302) == "/welcome" - - signed_out_html = - sign_out_response - |> recycle() - |> get(~p"/welcome") - |> html_response(200) - - assert signed_out_html =~ "Sign In" - refute signed_out_html =~ "phase58-ready-owner@example.com" - refute signed_out_html =~ "Create your admin account" - end - - @tag skip: "repo-local .spec workspace was removed" - test "58.3.3 phase 58 plan and current-truth specs remain aligned" do - phase_plan = - repo_file!(".planning/phase-58-welcome-bootstrap-and-ready-state-routing-foundation.md") - - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - auth_spec = repo_file!(".spec/specs/authentication_system.spec.md") - operator_spec = repo_file!(".spec/specs/operator_auth_settings.spec.md") - setup_spec = repo_file!(".spec/specs/setup_onboarding.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - - assert phase_plan =~ "[x] 58 Phase 58 - Welcome Bootstrap And Ready-State Routing Foundation" - assert phase_plan =~ "[x] 58.1 Section - Default Route And Redirect Ownership" - assert phase_plan =~ "[x] 58.2 Section - Welcome Surface Slimming And Handoff Copy" - assert phase_plan =~ "[x] 58.3 Section - Phase Integration Tests" - - assert baseline_spec =~ "test/jido_code_web/live/phase_fifty_eight_integration_test.exs" - assert auth_spec =~ "test/jido_code_web/live/phase_fifty_eight_integration_test.exs" - assert operator_spec =~ "test/jido_code_web/live/phase_fifty_eight_integration_test.exs" - assert setup_spec =~ "test/jido_code_web/live/phase_fifty_eight_integration_test.exs" - assert package_spec =~ "test/jido_code_web/live/phase_fifty_eight_integration_test.exs" - end - - defp continue_setup_config(owner_email) do - %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => owner_email, - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - }, - default_environment: :sprite, - workspace_root: nil - } - end - - defp ready_config do - %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - } - end - - defp assert_live_redirect({:error, {:redirect, %{to: path}}}, expected) when path == expected, - do: :ok - - defp assert_live_redirect({:error, {:live_redirect, %{to: path}}}, expected) - when path == expected, - do: :ok - - defp assert_live_redirect(other, expected) do - ExUnit.Assertions.flunk("expected live redirect to #{expected}, got: #{inspect(other)}") - end - - defp redirect_path({path, _flash}) when is_binary(path), do: path - defp redirect_path(path) when is_binary(path), do: path - - defp owner_sign_in_with_token_path(strategy, token) do - strategy_path = - strategy - |> Strategy.routes() - |> Enum.find_value(fn - {path, :sign_in_with_token} -> path - _other -> nil - end) - - path = - Path.join( - "/auth", - String.trim_leading(strategy_path || "/user/password/sign_in_with_token", "/") - ) - - query = URI.encode_query(%{"token" => token}) - "#{path}?#{query}" - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_fifty_five_integration_test.exs b/test/jido_code_web/live/phase_fifty_five_integration_test.exs deleted file mode 100644 index d3b177b2..00000000 --- a/test/jido_code_web/live/phase_fifty_five_integration_test.exs +++ /dev/null @@ -1,516 +0,0 @@ -defmodule JidoCodeWeb.PhaseFiftyFiveIntegrationTest do - # covers: architecture.memory_graph_workflow_and_operator_expansion.governed_surfaces_host_memory_context - # covers: architecture.memory_graph_workflow_and_operator_expansion.operator_memory_actions_use_product_owned_boundaries - # covers: architecture.memory_graph_workflow_and_operator_expansion.cross_graph_navigation_connects_memory_code_and_governed_history - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_promotions_create_governed_follow_up - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.operator_memory_actions_are_available_from_canonical_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.canonical_routes_remain_product_and_governed - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.Actor - alias JidoCode.Governance.{Decision, Evidence} - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope} - alias JidoCode.Operations.{Assessment, Event, WorkItem} - - setup do - previous = Application.get_env(:jido_code, :memory_graph_enabled, false) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, previous) - end) - - :ok - end - - test "55.6.2.1 canonical work-item surfaces host bounded memory context and shared actions", %{ - conn: _conn - } do - {authed_conn, _session_token} = authenticate_phase_owner!("work-item") - fixture = seed_governed_memory_surface_fixture!("work-item") - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{fixture.route_id}/work-items/#{fixture.work_item.id}", - on_error: :warn - ) - - assert has_element?(view, "#work-item-detail-title", fixture.work_item.summary) - - assert has_element?( - view, - "#work-item-detail-run-route-1[href=\"/repos/#{fixture.route_id}/runs/#{fixture.run.run_id}\"]", - "Open run" - ) - - assert has_element?(view, "#work-item-detail-memory-context-state", "ready") - - assert has_element?( - view, - "#work-item-detail-memory-follow-up-preview-route[href=\"/repos/#{fixture.route_id}/work-items/#{fixture.work_item.id}#work-item-detail-memory-context\"]" - ) - - assert has_element?( - view, - "#work-item-detail-memory-supersede-#{fixture.decision.id}", - "Supersede with latest decision" - ) - - render_click(element(view, "#work-item-detail-memory-validate-1")) - - assert has_element?( - view, - "#work-item-detail-memory-action-feedback", - "Recorded durable memory validation" - ) - end - - test "55.6.2.2 canonical evidence and decision surfaces keep navigation and actions product-owned", %{ - conn: _conn - } do - {authed_conn, _session_token} = authenticate_phase_owner!("evidence-decision") - fixture = seed_governed_memory_surface_fixture!("evidence-decision") - - {:ok, evidence_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{fixture.route_id}/evidence/#{fixture.evidence.id}", - on_error: :warn - ) - - assert has_element?(evidence_view, "#evidence-detail-title", fixture.evidence.summary) - - assert has_element?( - evidence_view, - "#evidence-detail-run-route[href=\"/repos/#{fixture.route_id}/runs/#{fixture.run.run_id}\"]", - "Open run" - ) - - assert has_element?( - evidence_view, - "#evidence-detail-work-item-route[href=\"/repos/#{fixture.route_id}/work-items/#{fixture.work_item.id}\"]", - "Open work item" - ) - - assert has_element?( - evidence_view, - "#evidence-detail-related-decision-route-1[href=\"/repos/#{fixture.route_id}/decisions/#{fixture.decision.id}\"]", - "Open decision" - ) - - render_click(element(evidence_view, "#evidence-detail-memory-promote-1")) - - assert has_element?( - evidence_view, - "#evidence-detail-memory-action-feedback", - "Created governed follow-up work item" - ) - - {:ok, decision_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{fixture.route_id}/decisions/#{fixture.decision.id}", - on_error: :warn - ) - - assert has_element?(decision_view, "#decision-detail-title", fixture.decision.decision_key) - - assert has_element?( - decision_view, - "#decision-detail-run-route[href=\"/repos/#{fixture.route_id}/runs/#{fixture.run.run_id}\"]", - "Open run" - ) - - assert has_element?( - decision_view, - "#decision-detail-work-item-route[href=\"/repos/#{fixture.route_id}/work-items/#{fixture.work_item.id}\"]", - "Open work item" - ) - - assert has_element?( - decision_view, - "#decision-detail-related-evidence-route-1[href=\"/repos/#{fixture.route_id}/evidence/#{fixture.evidence.id}\"]", - "Open evidence" - ) - - render_click(element(decision_view, "#decision-detail-memory-supersede")) - - assert has_element?( - decision_view, - "#decision-detail-memory-action-feedback", - "Superseded durable decision memory with the latest governed decision" - ) - end - - @tag skip: "repo-local .spec workspace was removed" - test "55.6.2.3 canonical governed memory routes and current-truth docs stay aligned", %{ - conn: _conn - } do - {authed_conn, _session_token} = authenticate_phase_owner!("missing") - %{route_id: route_id} = seed_governed_memory_surface_fixture!("missing") - - {:ok, work_item_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{route_id}/work-items/#{Ecto.UUID.generate()}", - on_error: :warn - ) - - {:ok, evidence_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{route_id}/evidence/#{Ecto.UUID.generate()}", - on_error: :warn - ) - - {:ok, decision_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{route_id}/decisions/#{Ecto.UUID.generate()}", - on_error: :warn - ) - - assert has_element?(work_item_view, "#work-item-detail-missing-title", "Work item not found") - assert has_element?(evidence_view, "#evidence-detail-missing-title", "Evidence not found") - assert has_element?(decision_view, "#decision-detail-missing-title", "Decision not found") - - phase_plan = repo_file!(".planning/phase-55-memory-rollout-and-governed-surfaces.md") - phase_fifty_four = repo_file!("test/jido_code_web/live/phase_fifty_four_integration_test.exs") - router = repo_file!("lib/jido_code_web/router.ex") - - assert phase_plan =~ "[x] 55.6 Section - Phase 55 Integration Tests" - assert phase_plan =~ "[x] 55.6.2 Task - Add governed-surface LiveView and integration coverage" - - assert repo_file!(".spec/specs/memory_graph_workflow_and_operator_expansion.spec.md") =~ - "status: active" - - assert repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") =~ - "status: active" - - assert phase_fifty_four =~ "assert proposed_specs() == []" - assert router =~ "live \"/repos/:id/work-items/:work_item_id\", WorkItemDetailLive" - assert router =~ "live \"/repos/:id/evidence/:evidence_id\", EvidenceDetailLive" - assert router =~ "live \"/repos/:id/decisions/:decision_id\", DecisionDetailLive" - - assert proposed_specs() == [] - end - - defp authenticate_phase_owner!(suffix) do - email = "phase55-#{suffix}-#{System.unique_integer([:positive])}@example.com" - password = "owner-password-123" - - register_owner(email, password) - authenticate_owner_conn(email, password) - end - - defp seed_governed_memory_surface_fixture!(suffix) do - workspace_path = create_memory_workspace_path!(suffix) - unique = System.unique_integer([:positive]) - - %{managed_repo: managed_repo, route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-phase55-#{suffix}-#{unique}", - github_full_name: "owner/repo-phase55-#{suffix}-#{unique}", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, event} = - Event.create( - %{ - managed_repo_id: managed_repo.id, - category: "memory_surface_review", - summary: "Phase 55 needs governed memory surface coverage.", - correlation_key: "phase55-memory-surface-#{unique}", - payload: %{}, - source_metadata: %{"source" => "phase_fifty_five_live_test"}, - occurred_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, assessment} = - Assessment.create( - %{ - managed_repo_id: managed_repo.id, - event_id: event.id, - category: "memory_surface_review", - summary: "Assess canonical governed memory route readiness.", - priority: :medium, - urgency: :medium, - recommended_action: "review_memory_context", - rationale: "Phase 55 should finish the governed memory route family.", - inputs: %{}, - assessment_metadata: %{"source" => "phase_fifty_five_live_test"}, - assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, work_item} = - WorkItem.create( - %{ - managed_repo_id: managed_repo.id, - assessment_id: assessment.id, - event_id: event.id, - category: "memory_review", - status: :open, - priority: :medium, - recommended_action: "review_memory_context", - summary: "Review bounded memory context on canonical governed routes.", - dedup_key: "phase55-work-item-#{unique}", - initiating_actor: %{"id" => "owner-55", "email" => "phase55@example.com"}, - work_metadata: %{}, - audit_log: [], - opened_at: DateTime.utc_now() |> DateTime.truncate(:microsecond), - last_assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - run = - create_governed_run!(route_id, %{ - run_id: "run-phase55-#{suffix}-#{unique}", - workflow_name: "implement_task", - workflow_version: 2, - status: :awaiting_approval, - current_step: "approval_gate", - current_stage: "approval_gate", - work_item_id: work_item.id, - started_at: ~U[2026-04-19 14:00:00Z] - }) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - key: "phase55_memory_context", - evidence_type: "memory_graph_finding", - summary: "Evidence memory context should stay on canonical governed routes.", - evidence_details: %{"source" => "phase_fifty_five_live_test"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "phase55-decision-#{unique}", - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - evidence_ids: [evidence.id], - decision: :approve, - actor: %{"id" => "owner-55", "email" => "phase55@example.com"}, - rationale: "Canonical governed memory routes are ready for rollout.", - decision_metadata: %{"source" => "phase_fifty_five_live_test"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - revision = "phase55-live-#{unique}" - - seed_governed_surface_memory!( - managed_repo.id, - workspace_path, - revision, - run.run_id, - work_item.id, - evidence.id, - decision.id - ) - - %{ - managed_repo: managed_repo, - route_id: route_id, - workspace_path: workspace_path, - run: run, - work_item: work_item, - evidence: evidence, - decision: decision - } - end - - defp create_memory_workspace_path!(suffix) do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_phase_fifty_five_live_#{suffix}_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule PhaseFiftyFiveLive.MixProject do - use Mix.Project - - def project do - [app: :phase_fifty_five_live, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - File.write!( - Path.join(workspace_path, "lib/example_phase_fifty_five_live.ex"), - """ - defmodule ExamplePhaseFiftyFiveLive do - def review(name) when is_binary(name), do: "review " <> name - end - """ - ) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp seed_governed_surface_memory!( - managed_repo_id, - workspace_path, - _requested_revision, - run_id, - work_item_id, - evidence_id, - decision_id - ) do - assert {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - revision = revision_metadata.current_revision - - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "phase55-memory-surface-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:phase-fifty-five-live", - workflow: :review, - work_item_id: work_item_id, - goal: "Seed canonical governed memory route coverage" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _review_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.review( - session_id: session_id, - actor_id: "system:phase-fifty-five-live", - workflow: :review, - work_item_id: work_item_id, - content: "Canonical governed surfaces share bounded memory context.", - anchors: %{module_name: "ExamplePhaseFiftyFiveLive"}, - governed_context: %{ - run_id: run_id, - work_item_id: work_item_id, - evidence_id: evidence_id, - decision_id: decision_id - } - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.known_issue( - session_id: session_id, - actor_id: "system:phase-fifty-five-live", - workflow: :review, - work_item_id: work_item_id, - content: "Canonical governed routes should keep durable memory explainable.", - revision: revision, - anchors: %{module_name: "ExamplePhaseFiftyFiveLive"}, - governed_context: %{ - run_id: run_id, - work_item_id: work_item_id, - evidence_id: evidence_id, - decision_id: decision_id - }, - classification: %{ - source: "phase_fifty_five_live_test", - reason: "Phase 55 needs bounded memory on work-item, evidence, and decision routes." - } - ), - revision: revision - ) - - assert {:ok, _decision_memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.decision( - session_id: session_id, - actor_id: "system:phase-fifty-five-live", - workflow: :review, - work_item_id: work_item_id, - content: "Earlier governed decisions should remain reviewable on canonical routes.", - rationale: "Shared memory actions need durable decision memory for supersession coverage.", - decision_status: :accepted, - revision: revision, - anchors: %{module_name: "ExamplePhaseFiftyFiveLive"}, - governed_context: %{ - run_id: run_id, - work_item_id: work_item_id, - evidence_id: evidence_id, - decision_id: decision_id - }, - classification: %{ - source: "phase_fifty_five_live_test", - reason: "Phase 55 supersession coverage needs durable decision memory." - } - ), - revision: revision - ) - end - - defp proposed_specs do - repo_root() - |> Path.join(".spec/specs/*.spec.md") - |> Path.wildcard() - |> Enum.filter(&(File.read!(&1) =~ "status: proposed")) - |> Enum.map(&Path.basename/1) - |> Enum.sort() - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_fifty_four_integration_test.exs b/test/jido_code_web/live/phase_fifty_four_integration_test.exs deleted file mode 100644 index d5cfc878..00000000 --- a/test/jido_code_web/live/phase_fifty_four_integration_test.exs +++ /dev/null @@ -1,113 +0,0 @@ -defmodule JidoCodeWeb.PhaseFiftyFourIntegrationTest do - # covers: architecture.factory_control_plane.internal_repo_loaders_use_canonical_repo_graph - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.run_governance.greenfield_tests_and_fixtures_create_canonical_run_graph - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.Actor - alias JidoCode.Orchestration.WorkflowRun - - test "54.5.1 canonical repo and governed-run helpers drive run detail without legacy setup", %{ - conn: _conn - } do - register_owner("phase54-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase54-owner@example.com", "owner-password-123") - - %{managed_repo: managed_repo, route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-phase54-canonical", - github_full_name: "owner/repo-phase54-canonical", - default_branch: "main", - settings: %{} - }) - - run_id = "run-phase54-#{System.unique_integer([:positive])}" - - run = - create_governed_run!(route_id, %{ - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - status: :completed, - current_step: "publish_pr", - current_stage: "publish_pr", - started_at: ~U[2026-04-19 12:00:00Z], - completed_at: ~U[2026-04-19 12:03:00Z] - }) - - assert run.managed_repo_id == managed_repo.id - - assert {:ok, [%WorkflowRun{id: workflow_run_id, project_id: workflow_project_id}]} = - WorkflowRun.read( - query: [filter: [run_id: run_id], limit: 1], - actor: Actor.operator_actor() - ) - - assert run.workflow_run_id == workflow_run_id - assert run.legacy_project_id == workflow_project_id - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{route_id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(view, "#run-detail-title", "Run detail") - assert has_element?(view, "#run-detail-run-id", run_id) - assert has_element?(view, "#run-detail-status", "completed") - assert has_element?(view, "#run-detail-current-step", "publish_pr") - assert has_element?(view, "#run-detail-current-stage", "cleanup") - end - - @tag skip: "repo-local .spec workspace was removed" - test "54.5.2 specs, planning, and contributor docs stay aligned after drift closure" do - phase_plan = repo_file!(".planning/phase-54-drift-closure-and-current-truth-convergence.md") - planning_readme = repo_file!(".planning/README.md") - readme = repo_file!("README.md") - contributing = repo_file!("CONTRIBUTING.md") - - assert phase_plan =~ "[x] 54 Phase 54 - Drift Closure And Current-Truth Convergence" - assert phase_plan =~ "[x] 54.5 Section - Phase 54 Integration Tests" - assert phase_plan =~ "[x] 54.5.2 Task - Verify current-truth convergence across specs, planning, and docs" - - assert planning_readme =~ "Phase 54 - Drift Closure And Current-Truth Convergence" - assert planning_readme =~ "Chronology note: repo integration coverage already includes Phase 55-style" - - assert readme =~ "canonical repository or managed-repository language" - assert readme =~ "explicit compatibility, migration, or audit seams" - - assert contributing =~ "provision_managed_repo!/1" - assert contributing =~ "create_governed_run!/2" - assert contributing =~ "compatibility, migration, or audit coverage" - - assert repo_file!(".spec/specs/agent_os_integration.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/source_code_graph_pod.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/source_code_graph_product_adoption.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/memory_capture_plane.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/memory_graph.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/memory_graph_product_adoption.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/memory_graph_workflow_and_operator_expansion.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") =~ "status: active" - assert repo_file!(".spec/specs/memory_ontology.spec.md") =~ "status: active" - - assert proposed_specs() == [] - end - - defp proposed_specs do - repo_root() - |> Path.join(".spec/specs/*.spec.md") - |> Path.wildcard() - |> Enum.filter(&(File.read!(&1) =~ "status: proposed")) - |> Enum.map(&Path.basename/1) - |> Enum.sort() - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_fifty_nine_integration_test.exs b/test/jido_code_web/live/phase_fifty_nine_integration_test.exs deleted file mode 100644 index d1368ccc..00000000 --- a/test/jido_code_web/live/phase_fifty_nine_integration_test.exs +++ /dev/null @@ -1,221 +0,0 @@ -defmodule JidoCodeWeb.PhaseFiftyNineIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: auth.operator_settings.sections_separated - # covers: auth.operator_settings.broker_trust_configuration_ui - # covers: auth.operator_settings.github_service_validation_feedback - # covers: auth.operator_settings.integration_boundary_visible - # covers: auth.operator_settings.hidden_during_bootstrap_entry - # covers: baseline.surface.welcome_landing_copy - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AuthProviders.ProviderConfig - - @checker_env :setup_github_credential_checker - @checked_at ~U[2026-03-15 18:30:00Z] - - setup do - original_checker = Application.get_env(:jido_code, @checker_env, :__missing__) - original_config = Application.get_env(:jido_code, :system_config, :__missing__) - original_loader = Application.get_env(:jido_code, :system_config_loader, :__missing__) - - on_exit(fn -> - restore_env(@checker_env, original_checker) - restore_env(:system_config, original_config) - restore_env(:system_config_loader, original_loader) - end) - - Application.delete_env(:jido_code, :system_config_loader) - Application.put_env(:jido_code, :system_config, ready_config()) - - :ok - end - - test "59.3.1 settings auth surface owns provider login and github validation while welcome stays a handoff", - %{conn: conn} do - Application.put_env(:jido_code, @checker_env, fn _context -> - blocked_github_report() - end) - - register_owner("phase59-owner@example.com", "owner-password-123") - - assert_live_redirect(live(conn, ~p"/settings/auth", on_error: :warn), "/welcome") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("phase59-owner@example.com", "owner-password-123", return_owner: true) - - {:ok, settings_view, _html} = live(recycle(authed_conn), ~p"/settings/auth") - - assert has_element?(settings_view, "#settings-auth-provider-login-settings", "Provider Login") - - assert has_element?( - settings_view, - "#settings-auth-git-provider-integrations", - "Git Provider Integrations" - ) - - settings_view - |> form("#settings-auth-provider-login-form-github", %{ - "provider_login" => %{ - "provider" => "github", - "enabled" => "true", - "login_enabled" => "true", - "allowlist_mode" => "organizations", - "allowlist_values" => "agentjido\njido-labs", - "broker_base_url" => "https://broker.example.com", - "broker_issuer" => "https://broker.example.com", - "broker_audience" => "jido-code" - } - }) - |> render_submit() - - assert has_element?(settings_view, "#settings-auth-provider-login-card-github", "Ready") - - {:ok, config} = - ProviderConfig.get_by_provider_host(:github, "github.com", authorize?: false) - - assert config.enabled == true - assert config.login_enabled == true - assert config.allowlist_mode == :organizations - assert config.allowlist_values == ["agentjido", "jido-labs"] - - Application.put_env(:jido_code, @checker_env, fn _context -> - ready_github_report() - end) - - settings_view - |> element("#settings-auth-refresh-github-service-checks") - |> render_click() - - assert has_element?(settings_view, "#settings-auth-github-service-status", "GitHub automation is ready.") - - assert has_element?( - settings_view, - "#settings-auth-github-service-path-github_app", - "GitHub App credentials are ready." - ) - - {:ok, welcome_view, _html} = live(recycle(authed_conn), ~p"/welcome") - - assert has_element?( - welcome_view, - ~s|a[href="/settings/auth"]#welcome-open-settings|, - "Open Auth & Integrations" - ) - - assert has_element?(welcome_view, "#welcome-ready-handoff-note", "Dashboard is the default authenticated entry") - refute has_element?(welcome_view, "#welcome-operator-settings-handoff") - refute has_element?(welcome_view, "#provider-login-settings") - refute has_element?(welcome_view, "#git-provider-integrations") - end - - @tag skip: "repo-local .spec workspace was removed" - test "59.3.2 phase 59 plan and current-truth specs remain aligned" do - phase_plan = - repo_file!(".planning/phase-59-operator-auth-settings-settings-surface-adoption.md") - - operator_spec = repo_file!(".spec/specs/operator_auth_settings.spec.md") - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - auth_spec = repo_file!(".spec/specs/authentication_system.spec.md") - - assert phase_plan =~ "[x] 59 Phase 59 - Operator Auth Settings Settings-Surface Adoption" - assert phase_plan =~ "[x] 59.1 Section - Settings Information Architecture And Route Ownership" - assert phase_plan =~ "[x] 59.2 Section - Welcome-To-Settings Handoff And Surface Cleanup" - assert phase_plan =~ "[x] 59.3 Section - Phase Integration Tests" - - assert operator_spec =~ "signed-in `/welcome` remains only a compact handoff into dashboard and settings" - assert operator_spec =~ "test/jido_code_web/live/phase_fifty_nine_integration_test.exs" - assert baseline_spec =~ "test/jido_code_web/live/phase_fifty_nine_integration_test.exs" - assert package_spec =~ "test/jido_code_web/live/phase_fifty_nine_integration_test.exs" - assert auth_spec =~ "compact dashboard/settings handoff" - end - - defp ready_config do - %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - } - end - - defp blocked_github_report do - %{ - checked_at: @checked_at, - status: :blocked, - owner_context: "phase59-owner@example.com", - paths: [ - %{ - path: :github_app, - name: "GitHub App", - status: :not_configured, - detail: "GitHub App credentials are not fully configured.", - remediation: "Set GitHub App credentials and retry validation.", - repository_access: :unconfirmed, - checked_at: @checked_at - }, - %{ - path: :pat, - name: "Personal Access Token (PAT)", - status: :invalid, - detail: "PAT fallback is unavailable until a deployment-local token is configured.", - remediation: "Set a valid GitHub PAT or prefer a GitHub App installation token.", - repository_access: :unconfirmed, - checked_at: @checked_at - } - ] - } - end - - defp ready_github_report do - %{ - checked_at: @checked_at, - status: :ready, - owner_context: "phase59-owner@example.com", - paths: [ - %{ - path: :github_app, - name: "GitHub App", - status: :ready, - detail: "GitHub App credentials are ready.", - remediation: "GitHub App path is ready.", - repository_access: :confirmed, - repositories: ["agentjido/jido_code"], - validated_at: @checked_at, - checked_at: @checked_at - }, - %{ - path: :pat, - name: "Personal Access Token (PAT)", - status: :ready, - detail: "PAT fallback confirms repository access.", - remediation: "PAT fallback is available.", - repository_access: :confirmed, - repositories: ["agentjido/jido_code"], - validated_at: @checked_at, - checked_at: @checked_at - } - ] - } - end - - defp assert_live_redirect({:error, {:redirect, %{to: path}}}, expected) when path == expected, - do: :ok - - defp assert_live_redirect({:error, {:live_redirect, %{to: path}}}, expected) - when path == expected, - do: :ok - - defp assert_live_redirect(other, expected) do - ExUnit.Assertions.flunk("expected live redirect to #{expected}, got: #{inspect(other)}") - end - - defp repo_file!(relative_path) do - relative_path - |> Path.expand(File.cwd!()) - |> File.read!() - end -end diff --git a/test/jido_code_web/live/phase_fourteen_integration_test.exs b/test/jido_code_web/live/phase_fourteen_integration_test.exs deleted file mode 100644 index 0253402b..00000000 --- a/test/jido_code_web/live/phase_fourteen_integration_test.exs +++ /dev/null @@ -1,306 +0,0 @@ -defmodule JidoCodeWeb.PhaseFourteenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.live_vue_is_canonical_rich_component_bridge - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.testing_keeps_liveview_and_adds_live_vue_aware_helpers - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - # covers: architecture.runtime_service_overlay.runtime_topology_details_remain_opaque_to_product - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Governance.RepoPosture - alias JidoCode.Orchestration.WorkflowRun - alias JidoCode.Projects.Project - - setup do - original_workbench_loader = - Application.get_env(:jido_code, :workbench_inventory_loader, :__missing__) - - on_exit(fn -> - restore_env(:workbench_inventory_loader, original_workbench_loader) - end) - - :ok - end - - test "settings overview keeps the route liveview-owned while exposing bounded vue interaction", %{ - conn: _conn - } do - register_owner("phase14-settings-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase14-settings-owner@example.com", "owner-password-123") - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/github", on_error: :warn) - - widget = assert_vue_component(view, "SettingsOverviewWidget", id: "settings-overview-widget") - - assert widget.props["activeTab"] == "github" - assert widget.props["openAddRepoVisible"] == true - assert_vue_handler(view, "openAddRepo", "open_add_modal", id: "settings-overview-widget") - - assert has_element?(view, "button[phx-click='open_add_modal']", "Add Repository") - refute has_element?(view, "#add-repo-modal") - - view - |> element("button[phx-click='open_add_modal']") - |> render_click() - - assert has_element?(view, "#add-repo-modal") - end - - test "workbench summary widget tracks liveview-owned filter state changes", %{conn: _conn} do - register_owner("phase14-workbench-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase14-workbench-owner@example.com", "owner-password-123") - - now = DateTime.utc_now() - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-alpha", - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - open_issue_count: 5, - open_pr_count: 0, - recent_activity_summary: "Alpha summary", - recent_activity_at: DateTime.add(now, -45 * 24 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: "owner-repo-beta", - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 0, - open_pr_count: 4, - recent_activity_summary: "Beta summary", - recent_activity_at: DateTime.add(now, -2 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: "owner-repo-gamma", - name: "repo-gamma", - github_full_name: "owner/repo-gamma", - open_issue_count: 2, - open_pr_count: 1, - recent_activity_summary: "Gamma summary", - recent_activity_at: DateTime.add(now, -10 * 24 * 60 * 60, :second) |> DateTime.to_iso8601() - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - widget = assert_vue_component(view, "WorkbenchSummaryWidget", id: "workbench-summary-widget") - - assert widget.props["inventoryCount"] == 3 - assert widget.props["inventoryTotalCount"] == 3 - assert widget.props["resetVisible"] == false - assert_vue_handler(view, "resetFilters", "reset_filters", id: "workbench-summary-widget") - - assert has_element?(view, "#workbench-filters-form") - assert has_element?(view, "#workbench-filter-results-count", "Showing 3 of 3") - - apply_workbench_filters(view, %{"project_id" => "owner-repo-beta"}) - - updated_widget = vue(view, id: "workbench-summary-widget") - - assert updated_widget.props["inventoryCount"] == 1 - assert updated_widget.props["resetVisible"] == true - assert has_element?(view, "#workbench-project-name-owner-repo-beta", "owner/repo-beta") - refute has_element?(view, "#workbench-project-name-owner-repo-alpha") - end - - test "repo detail overview keeps workflow state server-authored", %{conn: _conn} do - register_owner("phase14-project-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase14-project-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-phase14-project-detail", - github_full_name: "owner/repo-phase14-project-detail", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - widget = - assert_vue_component(view, "ProjectDetailOverviewWidget", id: "project-detail-overview-widget") - - assert widget.props["githubFullName"] == "owner/repo-phase14-project-detail" - assert widget.props["launchReady"] == true - assert length(widget.props["workflowCards"]) == 2 - refute render(view) =~ "Start conversation" - end - - test "run detail governance overview keeps runtime evidence bounded and product-oriented", %{ - conn: _conn - } do - register_owner("phase14-run-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase14-run-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-phase14-run-detail", - github_full_name: "owner/repo-phase14-run-detail", - default_branch: "main", - settings: %{} - }) - - run_id = "run-phase14-detail-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render governed runtime evidence."}, - input_metadata: %{"task_summary" => %{required: true, source: "test"}}, - initiating_actor: %{id: "owner-1", email: "phase14-run-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-01 12:00:00Z], - step_results: %{ - "diff_summary" => "2 files changed (+9/-1).", - "runtime_service_delivery" => %{ - "delivery_mode" => "replay_recovery", - "reason_code" => "live_delivery_detached", - "terminal_handoff_kind" => "replay_terminal_lookup", - "terminal_state" => "completed", - "summary" => "Runtime delivery repaired through replay recovery." - } - } - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-01 12:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-01 12:02:00Z] - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, _repo_posture} = - RepoPosture.upsert_for_managed_repo( - %{ - managed_repo_id: managed_repo.id, - summary: "Repo posture remains governed.", - overall_trust: "medium", - execution_readiness: "medium", - validation_reliability: "high", - review_burden: "high", - drift_rate: "low", - recovery_resilience: "medium", - requirements_confidence: "high", - supervision_mode: "guided", - escalation_status: "review", - contributing_check_ids: [], - posture_metadata: %{ - "runtime_service_evidence_summary" => - "Runtime evidence requires operator review before execution trust can be restored.", - "runtime_service_evidence_state" => %{ - "status" => "degraded", - "review_required" => true, - "runtime_delivery" => %{ - "delivery_mode" => "replay_recovery", - "reason_code" => "live_delivery_detached" - }, - "integration_outcomes" => %{ - "latest_invocation" => %{ - "provider" => "github", - "summary" => "github installation delivery recovered" - } - } - } - } - }, - actor: Actor.operator_actor() - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - widget = - assert_vue_component( - view, - "RunGovernanceOverviewWidget", - id: "run-detail-governance-overview-widget" - ) - - assert widget.props["runStatus"] == "awaiting_approval" - assert widget.props["runtimeEvidence"]["statusLabel"] == "review required" - refute Map.has_key?(widget.props["runtimeEvidence"], "turnId") - refute Map.has_key?(widget.props["runtimeEvidence"], "sessionId") - - assert has_element?(view, "#run-detail-runtime-evidence") - - assert has_element?( - view, - "#run-detail-runtime-evidence-summary", - "operator review before execution trust can be restored" - ) - - assert has_element?(view, "#run-detail-runtime-evidence-note", "Product governance stores bounded runtime evidence") - end - - defp create_workspace_path! do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido-code-phase-14-workspace-#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf(workspace_path) end) - workspace_path - end - - defp apply_workbench_filters(view, overrides) do - filter_params = Map.merge(default_workbench_filter_params(), overrides) - - view - |> element("#workbench-filters-form") - |> render_change(%{"filters" => filter_params}) - end - - defp default_workbench_filter_params do - %{ - "project_id" => "all", - "work_state" => "all", - "freshness_window" => "any", - "sort_order" => "project_name_asc" - } - end -end diff --git a/test/jido_code_web/live/phase_seventeen_integration_test.exs b/test/jido_code_web/live/phase_seventeen_integration_test.exs deleted file mode 100644 index 2f920982..00000000 --- a/test/jido_code_web/live/phase_seventeen_integration_test.exs +++ /dev/null @@ -1,235 +0,0 @@ -defmodule JidoCodeWeb.PhaseSeventeenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: baseline.surface.product_routes_declared - # covers: setup.onboarding.post_bootstrap_surfaces_adopt_control_plane_language - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Governance.RepoPosture - alias JidoCode.Orchestration.WorkflowRun - alias JidoCode.Projects.Project - - setup do - original_system_config_loader = - Application.get_env(:jido_code, :system_config_loader, :__missing__) - - on_exit(fn -> - restore_env(:system_config_loader, original_system_config_loader) - end) - - :ok - end - - test "removed compatibility routes and docs stay absent while canonical repo surfaces remain advertised", - %{conn: conn} do - assert conn |> get("/projects") |> html_response(404) - assert build_conn() |> get("/projects/legacy-repo") |> html_response(404) - assert build_conn() |> get("/projects/legacy-repo/runs/legacy-run") |> html_response(404) - - router = repo_file!("lib/jido_code_web/router.ex") - readme = repo_file!("README.md") - contributing = repo_file!("CONTRIBUTING.md") - agents = repo_file!("AGENTS.md") - phase_plan = repo_file!(".planning/phase-17-compatibility-era-removal-and-canonical-cutover.md") - - assert router =~ ~s|live("/repos", ProjectInventoryLive, :index)| - assert router =~ ~s|live("/repos/:id", ProjectDetailLive, :show)| - assert router =~ ~s|live("/repos/:id/runs/:run_id", RunDetailLive, :show)| - refute router =~ ~s|live("/projects"| - - refute readme =~ "/projects" - refute contributing =~ "/projects" - refute agents =~ "/projects" - refute readme =~ "compatibility rollout" - refute contributing =~ "compatibility rollout" - refute agents =~ "compatibility rollout" - - refute File.exists?(Path.expand("lib/jido_code/control/compatibility_rollout.ex", repo_root())) - refute File.exists?(Path.expand("test/jido_code/control/compatibility_rollout_test.exs", repo_root())) - refute File.exists?(Path.expand("test/jido_code_web/live/phase_six_integration_test.exs", repo_root())) - - assert phase_plan =~ "[x] 17 Phase 17 - Compatibility Era Removal and Canonical Cutover" - assert phase_plan =~ "[x] 17.4 Section - Phase 17 Integration Tests" - end - - test "fresh bootstrap and canonical repo and run flows work without compatibility shims", - %{conn: conn} do - Application.put_env(:jido_code, :system_config_loader, fn -> - {:ok, - %{ - onboarding_completed: false, - onboarding_step: 1, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }} - end) - - {:ok, _welcome_view, welcome_html} = live(conn, ~p"/welcome") - assert welcome_html =~ "Create your admin account" - assert {:error, {:live_redirect, %{to: "/welcome"}}} = live(conn, ~p"/setup", on_error: :warn) - - Application.put_env(:jido_code, :system_config_loader, fn -> - {:ok, - %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }} - end) - - register_owner("phase17-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase17-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-phase17-canonical", - github_full_name: "owner/repo-phase17-canonical", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, inventory_view, _html} = live(recycle(authed_conn), ~p"/repos", on_error: :warn) - - assert has_element?(inventory_view, "#project-inventory-table") - assert has_element?(inventory_view, "h1", "Repositories") - - assert render(inventory_view) =~ ~s(href="/repos/#{managed_repo.id}) - - {:ok, detail_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{managed_repo.id}", on_error: :warn) - - assert has_element?(detail_view, "#project-detail-title", "Managed repo detail") - refute render(detail_view) =~ "Start conversation" - - run_id = "run-phase17-canonical-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Phase 17 canonical run surface"}, - input_metadata: %{"task_summary" => %{required: true, source: "integration_test"}}, - initiating_actor: %{id: "owner-1", email: "phase17-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-04 12:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-04 12:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-04 12:02:00Z] - }) - - {:ok, _repo_posture} = - RepoPosture.upsert_for_managed_repo( - %{ - managed_repo_id: managed_repo.id, - summary: "Repo posture remains governed.", - overall_trust: "medium", - execution_readiness: "medium", - validation_reliability: "high", - review_burden: "high", - drift_rate: "low", - recovery_resilience: "medium", - requirements_confidence: "high", - supervision_mode: "guided", - escalation_status: "review", - contributing_check_ids: [], - posture_metadata: %{ - "runtime_service_evidence_summary" => - "Runtime delivery remains bounded to product evidence after compatibility seams were removed.", - "runtime_service_evidence_state" => %{ - "status" => "degraded", - "runtime_delivery" => %{ - "delivery_mode" => "live_subscription", - "reason_code" => "terminal_review_required" - }, - "integration_outcomes" => %{ - "latest_invocation" => %{ - "provider" => "github", - "summary" => "github delivery remained canonical" - } - } - } - } - }, - actor: Actor.operator_actor() - ) - - {:ok, run_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{managed_repo.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(run_view, "#run-detail-title", "Run detail") - assert has_element?(run_view, "#run-detail-status", "awaiting_approval") - assert has_element?(run_view, "#run-detail-runtime-evidence") - - assert has_element?( - run_view, - "#run-detail-runtime-evidence-summary", - "Runtime delivery remains bounded to product evidence after compatibility seams were removed." - ) - - assert has_element?(run_view, "#run-detail-runtime-evidence-status", "review required") - assert has_element?(run_view, "#run-detail-runtime-evidence-delivery-mode", "live subscription") - assert has_element?(run_view, "#run-detail-runtime-evidence-reason", "terminal review required") - assert has_element?(run_view, "#run-detail-runtime-evidence-integration", "github delivery remained canonical") - assert has_element?(run_view, "#run-detail-runtime-evidence-note", "runtime transport remains opaque") - - rendered_run_detail = render(run_view) - refute rendered_run_detail =~ "/projects/" - refute rendered_run_detail =~ "compatibility rollout" - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end - - defp create_workspace_path! do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido-code-phase-17-workspace-#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf(workspace_path) end) - workspace_path - end -end diff --git a/test/jido_code_web/live/phase_sixteen_integration_test.exs b/test/jido_code_web/live/phase_sixteen_integration_test.exs deleted file mode 100644 index 4c27be11..00000000 --- a/test/jido_code_web/live/phase_sixteen_integration_test.exs +++ /dev/null @@ -1,146 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixteenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: developer.workflow.phoenix_mix_surface - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - setup do - original_workbench_loader = - Application.get_env(:jido_code, :workbench_inventory_loader, :__missing__) - - original_support_agent_updater = - Application.get_env(:jido_code, :support_agent_config_project_updater, :__missing__) - - original_system_config_loader = - Application.get_env(:jido_code, :system_config_loader, :__missing__) - - Application.put_env(:jido_code, :system_config_loader, fn -> - {:ok, - %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }} - end) - - on_exit(fn -> - restore_env(:workbench_inventory_loader, original_workbench_loader) - restore_env(:support_agent_config_project_updater, original_support_agent_updater) - restore_env(:system_config_loader, original_system_config_loader) - end) - - :ok - end - - test "repo start paths and contributor docs converge on the repo-owned mix server flow" do - readme = repo_file!("README.md") - contributing = repo_file!("CONTRIBUTING.md") - tauri_readme = repo_file!("tauri/README.md") - mixfile = repo_file!("mix.exs") - phase_plan = repo_file!(".planning/phase-16-internal-cleanup-and-ui-convergence-foundation.md") - - assert readme =~ "mix server" - assert contributing =~ "mix server" - assert tauri_readme =~ "mix server" - refute readme =~ "mix phx.server" - refute contributing =~ "mix phx.server" - refute tauri_readme =~ "mix phx.server" - assert mixfile =~ "server: [\"frontend.start\", \"phx.server\"]" - assert phase_plan =~ "[x] 16 Phase 16 - Internal Cleanup and UI Convergence Foundation" - assert phase_plan =~ "[x] 16.3 Section - Phase 16 Integration Tests" - end - - test "hybrid and plain liveview operator surfaces share the standardized notice structure", %{ - conn: _conn - } do - register_owner("phase16-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase16-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, [], - %{ - error_type: "workbench_inventory_stale", - detail: "Workbench data is behind the latest repository updates.", - remediation: "Retry the inventory refresh after verifying setup diagnostics." - }} - end) - - %{managed_repo: managed_repo} = - provision_managed_repo!(%{ - name: "phase16-agent-project", - github_full_name: "owner/phase16-agent-project", - default_branch: "main", - integration_settings: %{ - "support_agent_config" => %{ - "github_issue_bot" => %{"enabled" => true} - } - } - }) - - Application.put_env(:jido_code, :support_agent_config_project_updater, fn _project, _attrs -> - {:error, :forced_support_agent_config_failure} - end) - - {:ok, workbench_view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(workbench_view, "#workbench-stale-warning-label", "Workbench data may be stale") - assert has_element?(workbench_view, "#workbench-stale-warning-type", "workbench_inventory_stale") - - assert has_element?( - workbench_view, - "#workbench-stale-warning-detail", - "Workbench data is behind the latest repository updates." - ) - - assert has_element?( - workbench_view, - "#workbench-stale-warning-remediation", - "Retry the inventory refresh after verifying setup diagnostics." - ) - - {:ok, agents_view, _html} = live(recycle(authed_conn), ~p"/agents", on_error: :warn) - - agents_view - |> element("#agents-issue-bot-disable-#{managed_repo.id}") - |> render_click() - - assert has_element?( - agents_view, - "#agents-issue-bot-error-label", - "Issue Bot configuration update failed" - ) - - assert has_element?( - agents_view, - "#agents-issue-bot-error-type", - "support_agent_config_persistence_failed" - ) - - assert has_element?( - agents_view, - "#agents-issue-bot-error-detail", - "Issue Bot configuration persistence failed" - ) - - assert has_element?( - agents_view, - "#agents-issue-bot-error-remediation", - "Retry the Issue Bot toggle. If this persists, verify repository settings persistence health." - ) - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_five_integration_test.exs b/test/jido_code_web/live/phase_sixty_five_integration_test.exs deleted file mode 100644 index 1bd55e80..00000000 --- a/test/jido_code_web/live/phase_sixty_five_integration_test.exs +++ /dev/null @@ -1,159 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtyFiveIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_memory_summaries_remain_bounded_and_action_oriented - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Repo - - setup do - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - :ok - end - - test "65.5.1 dashboard concern tabs keep one authenticated route while bounded panels switch in place", - %{conn: _conn} do - register_owner("phase65-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase65-owner@example.com", "owner-password-123") - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?onboarding=completed", on_error: :warn) - - assert has_element?(view, "#dashboard-root[data-dashboard-section='overview']") - assert has_element?(view, "#dashboard-overview-panel") - assert has_element?(view, "#dashboard-section-nav-next_steps") - - view - |> element("#dashboard-section-nav-runs") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=runs") - assert has_element?(view, "#dashboard-root[data-dashboard-section='runs']") - assert has_element?(view, "#dashboard-run-summaries") - refute has_element?(view, "#dashboard-overview-panel") - refute has_element?(view, "#dashboard-conversation-supervision") - - view - |> element("#dashboard-section-nav-conversations") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=conversations") - assert has_element?(view, "#dashboard-root[data-dashboard-section='conversations']") - assert has_element?(view, "#dashboard-conversation-supervision") - refute has_element?(view, "#dashboard-run-summaries") - - view - |> element("#dashboard-section-nav-memory") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=memory") - assert has_element?(view, "#dashboard-root[data-dashboard-section='memory']") - assert has_element?(view, "#dashboard-memory-summaries") - refute has_element?(view, "#dashboard-conversation-supervision") - - view - |> element("#dashboard-section-nav-runtime") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=runtime") - assert has_element?(view, "#dashboard-root[data-dashboard-section='runtime']") - assert has_element?(view, "#dashboard-runtime-evidence") - refute has_element?(view, "#dashboard-memory-summaries") - - view - |> element("#dashboard-section-nav-next_steps") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=next_steps") - assert has_element?(view, "#dashboard-root[data-dashboard-section='next_steps']") - assert has_element?(view, "#dashboard-onboarding-next-actions") - refute has_element?(view, "#dashboard-runtime-evidence") - end - - @tag skip: "repo-local .spec workspace was removed" - test "65.5.2 phase 65 plan, ADR, specs, and browser coverage remain aligned" do - phase_plan = - repo_file!(".planning/phase-65-dashboard-concern-tab-information-architecture.md") - - adr = repo_file!(".spec/decisions/jido_code.dashboard_concern_tabs_and_overview_handoff.md") - - monitoring_adr = - repo_file!(".spec/decisions/jido_code.dashboard_developer_centric_monitoring_sidebar.md") - - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - factory_spec = repo_file!(".spec/specs/factory_control_plane.spec.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - conversation_spec = repo_file!(".spec/specs/conversation_orchestration.spec.md") - memory_spec = repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") - repo_posture_spec = repo_file!(".spec/specs/repo_posture.spec.md") - runtime_spec = repo_file!(".spec/specs/runtime_service_overlay.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - browser_spec = repo_file!("test/e2e/dashboard-tabs.spec.ts") - - assert phase_plan =~ "[x] 65 Phase 65 - Dashboard Concern Tab Information Architecture" - assert phase_plan =~ "[x] 65.1 Section - Canonical Concern Families And Route Ownership" - assert phase_plan =~ "[x] 65.2 Section - Tab Shell And Overview Handoff" - assert phase_plan =~ "[x] 65.3 Section - Concern Panel Separation" - assert phase_plan =~ "[x] 65.4 Section - Current-Truth And Helper Convergence" - assert phase_plan =~ "[x] 65.5 Section - Phase Integration Tests" - - assert adr =~ "This decision is now landed in product code." - assert adr =~ "route-owned `section` selection" - assert adr =~ "evolved from a top rail into left-sidebar" - - assert monitoring_adr =~ "Phase 66 has landed" - assert monitoring_adr =~ "repository-first" - assert monitoring_adr =~ "monitoring feed that orders overview entries" - - assert baseline_spec =~ "left-sidebar concern navigation" - assert baseline_spec =~ "durable authenticated landing" - - assert factory_spec =~ "left-sidebar concern navigation" - assert factory_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - - assert frontend_spec =~ "dashboard as a LiveView-owned authenticated landing" - assert frontend_spec =~ "repository-first overview" - assert frontend_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - assert frontend_spec =~ "test/e2e/dashboard-tabs.spec.ts" - - assert conversation_spec =~ "sidebar-selected dashboard conversation concern" - assert conversation_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - - assert memory_spec =~ "sidebar-selected dashboard memory concern" - assert memory_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - - assert repo_posture_spec =~ "sidebar-selected runtime and posture concern" - assert repo_posture_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - - assert runtime_spec =~ "authenticated left-sidebar runtime concern" - assert runtime_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - - assert package_spec =~ ".planning/phase-65-dashboard-concern-tab-information-architecture.md" - assert package_spec =~ "test/jido_code_web/live/phase_sixty_five_integration_test.exs" - assert package_spec =~ "test/e2e/dashboard-tabs.spec.ts" - - assert browser_spec =~ - "dashboard sidebar keeps the ready-state landing route scanable on wide screens" - - assert browser_spec =~ - "dashboard sidebar navigation stays usable as a wrapped fallback on narrow screens" - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_four_integration_test.exs b/test/jido_code_web/live/phase_sixty_four_integration_test.exs deleted file mode 100644 index 8eb317ae..00000000 --- a/test/jido_code_web/live/phase_sixty_four_integration_test.exs +++ /dev/null @@ -1,351 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtyFourIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.conversation_orchestration.runtime_readiness_uses_managed_repo_workspace_binding - # covers: architecture.factory_control_plane.managed_repos_own_repo_scoped_workspace_binding - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Projects.Project - alias JidoCode.Workbench.ProjectDetail - - @managed_env_keys [:source_code_graph_enabled, :memory_graph_enabled] - - setup do - original_env = - Enum.map(@managed_env_keys, fn key -> - {key, Application.get_env(:jido_code, key, :__missing__)} - end) - - Application.put_env(:jido_code, :source_code_graph_enabled, true) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Enum.each(original_env, fn {key, value} -> - restore_env(key, value) - end) - end) - - :ok - end - - test "64.3.1 blocked repo-detail runtime families converge on one repo-scoped repair path", %{ - conn: _conn - } do - register_owner("phase64-blocked-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase64-blocked-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "phase-64-blocked-runtime-families", - github_full_name: "owner/phase-64-blocked-runtime-families", - default_branch: "main", - settings: blocked_local_settings() - }) - - _managed_repo_id = managed_repo_route_id!(project.id) - - {:ok, conversation_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=conversations", on_error: :warn) - - assert has_element?(conversation_view, "#project-detail-conversation-runtime-status", "Blocked") - - assert has_element?( - conversation_view, - "#project-detail-conversation-runtime-workspace", - "No repo-scoped local workspace path saved" - ) - - assert has_element?( - conversation_view, - "#project-detail-conversation-runtime-repair", - "Repair workspace binding" - ) - - conversation_view - |> element("#project-detail-conversation-runtime-repair") - |> render_click() - - assert has_element?(conversation_view, "#project-detail-overview-panel") - assert has_element?(conversation_view, "#project-detail-workspace-binding-panel") - - {:ok, semantic_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=semantic", on_error: :warn) - - assert has_element?( - semantic_view, - "#project-detail-semantic-notice-type", - "semantic_workspace_binding_unavailable" - ) - - assert has_element?( - semantic_view, - "#project-detail-semantic-repair-workspace", - "Repair workspace binding" - ) - - {:ok, memory_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=memory", on_error: :warn) - - assert has_element?( - memory_view, - "#project-detail-memory-notice-type", - "memory_workspace_binding_unavailable" - ) - - assert has_element?( - memory_view, - "#project-detail-memory-repair-workspace", - "Repair workspace binding" - ) - - {:ok, workflow_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=workflows", on_error: :warn) - - assert has_element?(workflow_view, "#project-detail-workflow-readiness-badge", "Blocked") - - assert has_element?( - workflow_view, - "#project-detail-launch-disabled-repair", - "Repair workspace binding" - ) - end - - test "64.3.2 unrelated repo paths stay legible and independently repairable across workbench and repo detail", - %{conn: _conn} do - register_owner("phase64-ready-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase64-ready-owner@example.com", "owner-password-123") - - ready_one_path = create_workspace_path!("phase64-ready-one") - ready_two_path = create_workspace_path!("phase64-ready-two") - repaired_path = create_workspace_path!("phase64-repaired-target") - - {:ok, ready_one} = - Project.create(%{ - name: "phase-64-ready-one", - github_full_name: "owner/phase-64-ready-one", - default_branch: "main", - settings: ready_local_settings(ready_one_path) - }) - - {:ok, ready_two} = - Project.create(%{ - name: "phase-64-ready-two", - github_full_name: "owner/phase-64-ready-two", - default_branch: "main", - settings: ready_local_settings(ready_two_path) - }) - - {:ok, repair_target} = - Project.create(%{ - name: "phase-64-repair-target", - github_full_name: "owner/phase-64-repair-target", - default_branch: "main", - settings: blocked_local_settings() - }) - - ready_one_route_id = managed_repo_route_id!(ready_one.id) - ready_two_route_id = managed_repo_route_id!(ready_two.id) - repair_target_route_id = managed_repo_route_id!(repair_target.id) - - {:ok, ready_one_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{ready_one.id}?section=conversations", on_error: :warn) - - assert has_element?(ready_one_view, "#project-detail-conversation-runtime-status", "Ready") - - assert has_element?( - ready_one_view, - "#project-detail-conversation-runtime-workspace", - ready_one_path - ) - - {:ok, ready_two_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{ready_two.id}?section=conversations", on_error: :warn) - - assert has_element?(ready_two_view, "#project-detail-conversation-runtime-status", "Ready") - - assert has_element?( - ready_two_view, - "#project-detail-conversation-runtime-workspace", - ready_two_path - ) - - {:ok, workbench_view, _html} = - live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - workbench_view, - "#workbench-project-semantic-hint-recovery-#{repair_target_route_id}[href='/repos/#{repair_target_route_id}#project-detail-workspace-binding-panel']", - "Open repo detail to repair workspace binding." - ) - - assert has_element?( - workbench_view, - "#workbench-project-memory-hint-recovery-#{repair_target_route_id}[href='/repos/#{repair_target_route_id}#project-detail-workspace-binding-panel']", - "Open repo detail to repair workspace binding." - ) - - {:ok, repair_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{repair_target.id}?section=conversations", on_error: :warn) - - repair_view - |> element("#project-detail-conversation-runtime-repair") - |> render_click() - - repair_view - |> form("#project-detail-workspace-binding-form", %{ - "workspace_binding" => %{ - "workspace_environment" => "local", - "workspace_path" => repaired_path - } - }) - |> render_submit() - - repair_view - |> element("#project-detail-overview-open-conversations") - |> render_click() - - assert has_element?(repair_view, "#project-detail-conversation-runtime-status", "Ready") - assert has_element?(repair_view, "#project-detail-conversation-runtime-workspace", repaired_path) - - {:ok, ready_one_detail} = ProjectDetail.load(ready_one_route_id) - {:ok, ready_two_detail} = ProjectDetail.load(ready_two_route_id) - {:ok, repaired_detail} = ProjectDetail.load(repair_target_route_id) - - assert ProjectDetail.workspace_path(ready_one_detail) == ready_one_path - assert ProjectDetail.workspace_path(ready_two_detail) == ready_two_path - assert ProjectDetail.workspace_path(repaired_detail) == repaired_path - end - - @tag skip: "repo-local .spec workspace was removed" - test "64.3.3 phase 64 plan, specs, decision, and integration coverage remain aligned" do - phase_plan = - repo_file!(".planning/phase-64-runtime-surface-workspace-convergence.md") - - decision = - repo_file!(".spec/decisions/jido_code.managed_repo_workspace_binding_is_repo_scoped.md") - - runtime_defaults_spec = repo_file!(".spec/specs/runtime_environment_defaults.spec.md") - setup_spec = repo_file!(".spec/specs/setup_onboarding.spec.md") - factory_spec = repo_file!(".spec/specs/factory_control_plane.spec.md") - conversation_spec = repo_file!(".spec/specs/conversation_orchestration.spec.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - source_spec = repo_file!(".spec/specs/source_code_graph_product_adoption.spec.md") - memory_spec = repo_file!(".spec/specs/memory_graph_product_adoption.spec.md") - - memory_rollout_spec = - repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") - - memory_expansion_spec = - repo_file!(".spec/specs/memory_graph_workflow_and_operator_expansion.spec.md") - - package_spec = repo_file!(".spec/specs/package.spec.md") - - assert phase_plan =~ "[x] 64 Phase 64 - Runtime Surface Workspace Convergence" - assert phase_plan =~ "[x] 64.1 Section - Repo-Scoped Runtime And Knowledge Surface Adoption" - assert phase_plan =~ "[x] 64.2 Section - Current-Truth And Contributor Convergence" - assert phase_plan =~ "[x] 64.3 Section - Phase Integration Tests" - - assert decision =~ "blocked conversation, semantic, memory, and workflow surfaces now share" - - assert runtime_defaults_spec =~ "conversation, semantic, memory, and workflow readiness plus repo-scoped repair" - assert runtime_defaults_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert setup_spec =~ "post-import runtime surfaces now keep blocked repo-scoped workspace remediation" - assert setup_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert factory_spec =~ - "conversation plus semantic plus memory plus workflow readiness now sharing one repo-scoped workspace-binding story" - - assert factory_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert conversation_spec =~ - "same repo-scoped workspace-binding vocabulary as semantic, memory, and workflow surfaces" - - assert conversation_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert frontend_spec =~ - "consistent repo-scoped runtime wording across conversations, semantic, memory, and workflows" - - assert frontend_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert source_spec =~ "blocked semantic states now using the same repo-scoped workspace-binding vocabulary" - assert source_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert memory_spec =~ "blocked memory states now using the same repo-scoped workspace-binding vocabulary" - assert memory_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert memory_rollout_spec =~ "same repo-scoped repair vocabulary and route-local repair path" - assert memory_rollout_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert memory_expansion_spec =~ "same repo-scoped repair vocabulary and route-local repair path" - assert memory_expansion_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - - assert package_spec =~ "converged repo-detail runtime wording" - assert package_spec =~ "test/jido_code_web/live/phase_sixty_four_integration_test.exs" - end - - defp blocked_local_settings do - %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - end - - defp ready_local_settings(workspace_path) when is_binary(workspace_path) do - %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - end - - defp create_workspace_path!(label) when is_binary(label) do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_#{label}_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_integration_test.exs b/test/jido_code_web/live/phase_sixty_integration_test.exs deleted file mode 100644 index 3ecf1315..00000000 --- a/test/jido_code_web/live/phase_sixty_integration_test.exs +++ /dev/null @@ -1,394 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtyIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: baseline.surface.public_entry_routes - # covers: baseline.surface.welcome_landing_copy - # covers: auth.system.ready_state_local_auth_handoff - # covers: auth.provider_login_flow.redirect_path_completion - # covers: auth.operator_settings.sections_separated - # covers: auth.operator_settings.github_service_validation_feedback - # covers: auth.operator_settings.hidden_during_bootstrap_entry - # covers: auth.self_hosted_provider_integration.login_and_service_ready - # covers: setup.onboarding.admin_bootstrap_completion_gate - # covers: setup.onboarding.post_bootstrap_start_surface - # covers: setup.onboarding.explicit_completion_path_to_dashboard - # covers: users.admin_system.bootstrap_admin - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AuthProviders.{BrokerNonceStore, BrokerState, ProviderConfig} - alias JidoCode.Repo - - @checker_env :setup_github_credential_checker - @resolver_env :provider_auth_broker_jwks_resolver - @checked_at ~U[2026-04-25 13:45:00Z] - - setup do - BrokerNonceStore.reset!() - - original_config = Application.get_env(:jido_code, :system_config, :__missing__) - original_loader = Application.get_env(:jido_code, :system_config_loader, :__missing__) - original_checker = Application.get_env(:jido_code, @checker_env, :__missing__) - original_resolver = Application.get_env(:jido_code, @resolver_env, :__missing__) - - on_exit(fn -> - restore_env(:system_config, original_config) - restore_env(:system_config_loader, original_loader) - restore_env(@checker_env, original_checker) - restore_env(@resolver_env, original_resolver) - end) - - Application.delete_env(:jido_code, :system_config_loader) - Application.delete_env(:jido_code, @checker_env) - - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 1, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }) - - :ok - end - - test "60.3.1 first-run bootstrap enters setup and completes into dashboard", %{conn: conn} do - {:ok, welcome_view, _html} = live(conn, ~p"/welcome", on_error: :warn) - - welcome_view - |> form("#welcome-owner-form", %{ - "owner" => %{ - "email" => "phase60-bootstrap-owner@example.com", - "password" => "owner-password-123", - "password_confirmation" => "owner-password-123" - } - }) - |> render_submit() - - auth_redirect_path = - welcome_view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/setup" - - assert_live_redirect(live(recycle(auth_response), ~p"/welcome", on_error: :warn), "/setup") - - {:ok, setup_view, _html} = live(recycle(auth_response), ~p"/setup", on_error: :warn) - - assert has_element?(setup_view, "#setup-complete-continue", "Continue to dashboard") - - setup_view - |> element("#setup-complete-continue") - |> render_click() - - assert_redirect(setup_view, "/dashboard?onboarding=completed") - - assert %{ - onboarding_completed: true, - onboarding_step: 4, - onboarding_state: %{ - "2" => %{"owner_email" => "phase60-bootstrap-owner@example.com"}, - "3" => %{"completion_note" => completion_note} - } - } = Application.get_env(:jido_code, :system_config) - - assert completion_note =~ "Setup completed." - - {:ok, dashboard_view, _html} = - live(recycle(auth_response), ~p"/dashboard?onboarding=completed", on_error: :warn) - - assert has_element?(dashboard_view, "#dashboard-entry-summary", "authenticated product home") - end - - test "60.3.2 ready-state local and provider sign-in land on dashboard while settings owns auth and integrations", - %{conn: conn} do - register_owner("phase60-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, ready_config()) - Application.put_env(:jido_code, @checker_env, fn _context -> ready_github_report() end) - - enable_provider_login!(:github, "github.com") - - {:ok, sign_in_view, _html} = live(conn, ~p"/sign-in", on_error: :warn) - - sign_in_view - |> form("form[action='/auth/user/password/sign_in']", %{ - "user" => %{ - "email" => "phase60-owner@example.com", - "password" => "owner-password-123" - } - }) - |> render_submit() - - auth_redirect_path = - sign_in_view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/dashboard" - - {:ok, welcome_view, _html} = live(recycle(auth_response), ~p"/welcome", on_error: :warn) - - assert has_element?(welcome_view, "#welcome-open-dashboard", "Open Dashboard") - - assert has_element?( - welcome_view, - ~s|a[href="/settings/auth"]#welcome-open-settings|, - "Open Auth & Integrations" - ) - - refute has_element?(welcome_view, "#provider-login-settings") - - {:ok, dashboard_view, _html} = live(recycle(auth_response), ~p"/dashboard", on_error: :warn) - - assert has_element?(dashboard_view, "#dashboard-entry-summary", "authenticated product home") - assert has_element?(dashboard_view, ~s|a[href="/settings/auth"]|, "Settings") - - {:ok, settings_view, _html} = live(recycle(auth_response), ~p"/settings/auth", on_error: :warn) - - assert has_element?(settings_view, "#settings-auth-provider-login-settings", "Provider Login") - - assert has_element?( - settings_view, - "#settings-auth-git-provider-integrations", - "Git Provider Integrations" - ) - - assert has_element?(settings_view, "#settings-auth-github-service-status", "GitHub automation is ready.") - - start_response = get(build_conn(), ~p"/auth/providers/github/start?provider_host=github.com") - redirected = redirected_to(start_response, 302) - state_token = redirected |> broker_state_from_redirect() |> Map.fetch!("state") - - assert {:ok, claims} = BrokerState.verify(state_token) - assert claims.redirect_path == "/dashboard" - - {issued_state, handoff_token} = - valid_broker_handoff("phase60-ready-owner", %{ - "provider_email" => "phase60-owner@example.com", - "provider_login" => "phase60-owner" - }) - - provider_response = - get( - build_conn(), - ~p"/auth/providers/github/complete?provider_host=github.com&state=#{issued_state.token}&handoff_token=#{handoff_token}" - ) - - assert redirected_to(provider_response, 302) == "/dashboard" - - {:ok, provider_dashboard_view, provider_dashboard_html} = - live(recycle(provider_response), ~p"/dashboard", on_error: :warn) - - assert has_element?( - provider_dashboard_view, - "#dashboard-entry-summary", - "authenticated product home" - ) - - assert provider_dashboard_html =~ "phase60-owner@example.com" - - {:ok, provider_settings_view, _html} = - live(recycle(provider_response), ~p"/settings/auth", on_error: :warn) - - assert has_element?(provider_settings_view, "#settings-auth-github-service-status", "GitHub automation is ready.") - assert has_element?(provider_settings_view, "#settings-auth-provider-login-card-github", "Ready") - end - - @tag skip: "repo-local .spec workspace was removed" - test "60.3.3 phase 60 plan, route specs, and contributor docs remain aligned" do - phase_plan = - repo_file!(".planning/phase-60-welcome-dashboard-and-settings-convergence.md") - - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - auth_spec = repo_file!(".spec/specs/authentication_system.spec.md") - provider_spec = repo_file!(".spec/specs/provider_login_flow.spec.md") - operator_spec = repo_file!(".spec/specs/operator_auth_settings.spec.md") - self_hosted_spec = repo_file!(".spec/specs/self_hosted_provider_integration.spec.md") - setup_spec = repo_file!(".spec/specs/setup_onboarding.spec.md") - user_spec = repo_file!(".spec/specs/user_administration.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - adr = repo_file!(".spec/decisions/jido_code.welcome_bootstrap_entry_with_dashboard_and_settings_handoff.md") - readme = repo_file!("README.md") - contributing = repo_file!("CONTRIBUTING.md") - - assert phase_plan =~ "[x] 60 Phase 60 - Welcome Dashboard And Settings Convergence" - assert phase_plan =~ "[x] 60.1 Section - Final Authenticated Entry Convergence" - assert phase_plan =~ "[x] 60.2 Section - Current-Truth, Docs, And Contributor Convergence" - assert phase_plan =~ "[x] 60.3 Section - Phase Integration Tests" - - assert baseline_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert auth_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert provider_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert operator_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert self_hosted_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert setup_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert user_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - assert package_spec =~ "test/jido_code_web/live/phase_sixty_integration_test.exs" - - assert adr =~ "This decision is now landed in product code." - - assert readme =~ "## Route Orientation" - assert readme =~ "/dashboard` is the durable ready-state authenticated landing" - assert readme =~ "/settings/auth` is the durable home" - - assert contributing =~ "## Route Orientation" - assert contributing =~ "/dashboard` is the durable ready-state authenticated landing" - assert contributing =~ "/settings/auth` is the durable home" - end - - defp ready_config do - %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - } - end - - defp ready_github_report do - %{ - checked_at: @checked_at, - status: :ready, - owner_context: "phase60-owner@example.com", - paths: [ - %{ - path: :github_app, - name: "GitHub App", - status: :ready, - detail: "GitHub App credentials are ready.", - remediation: "GitHub App path is ready.", - repository_access: :confirmed, - repositories: ["agentjido/jido_code"], - validated_at: @checked_at, - checked_at: @checked_at - } - ] - } - end - - defp enable_provider_login!(provider, provider_host, overrides \\ []) do - attrs = - %{ - provider: provider, - provider_host: provider_host, - enabled: true, - login_enabled: true, - allowlist_mode: :none, - allowlist_values: [], - broker_issuer: "https://broker.example.com", - broker_audience: "jido-code", - broker_base_url: "https://broker.example.com" - } - |> Map.merge(Map.new(overrides)) - - {:ok, config} = ProviderConfig.upsert(attrs, authorize?: false) - config - end - - defp valid_broker_handoff(nonce, claim_overrides \\ %{}) do - {:ok, issued_state} = issue_state(nonce) - - jwk = JOSE.JWK.generate_key({:okp, :Ed25519}) - - Application.put_env(:jido_code, @resolver_env, fn _config -> - {:ok, %{"keys" => [public_jwk_map(jwk)]}} - end) - - handoff_token = - handoff_token( - jwk, - %{ - "nonce" => nonce, - "exp" => DateTime.to_unix(DateTime.add(DateTime.utc_now(), 300, :second)) - } - |> Map.merge(claim_overrides) - ) - - {issued_state, handoff_token} - end - - defp issue_state(nonce, redirect_path \\ "/dashboard") do - BrokerState.issue( - %{ - provider: "github", - provider_host: "github.com", - broker_base_url: "https://broker.example.com", - redirect_path: redirect_path - }, - now: current_now(), - nonce: nonce - ) - end - - defp current_now do - DateTime.utc_now() |> DateTime.truncate(:second) - end - - defp handoff_token(jwk, overrides) do - claims = - Map.merge( - %{ - "iss" => "https://broker.example.com", - "aud" => "jido-code", - "nonce" => "phase60-ready-owner", - "provider" => "github", - "provider_host" => "github.com", - "provider_subject" => "12345", - "provider_login" => "octocat", - "provider_email" => "octocat@example.com", - "email_verified" => true, - "exp" => DateTime.to_unix(DateTime.add(DateTime.utc_now(), 300, :second)) - }, - overrides - ) - - {_, compact} = - jwk - |> JOSE.JWT.sign(%{"alg" => "EdDSA"}, JOSE.JWT.from_map(claims)) - |> JOSE.JWS.compact() - - compact - end - - defp public_jwk_map(jwk) do - jwk - |> JOSE.JWK.to_public_map() - |> elem(1) - end - - defp broker_state_from_redirect(redirected) do - redirected - |> URI.parse() - |> Map.fetch!(:query) - |> URI.decode_query() - end - - defp assert_live_redirect({:error, {:redirect, %{to: path}}}, expected) when path == expected, - do: :ok - - defp assert_live_redirect({:error, {:live_redirect, %{to: path}}}, expected) - when path == expected, - do: :ok - - defp assert_live_redirect(other, expected) do - ExUnit.Assertions.flunk("expected live redirect to #{expected}, got: #{inspect(other)}") - end - - defp redirect_path({path, _flash}) when is_binary(path), do: path - defp redirect_path(path) when is_binary(path), do: path - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_one_integration_test.exs b/test/jido_code_web/live/phase_sixty_one_integration_test.exs deleted file mode 100644 index 680bfae3..00000000 --- a/test/jido_code_web/live/phase_sixty_one_integration_test.exs +++ /dev/null @@ -1,173 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtyOneIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - # covers: architecture.factory_control_plane.operator_surfaces_distinguish_repo_intake_from_work_item_conversations - # covers: architecture.conversation_orchestration.managed_repo_routes_host_repo_conversations - # covers: architecture.conversation_orchestration.operator_surfaces_show_conversation_work_item_linkage - # covers: architecture.conversation_orchestration.route_level_runtime_readiness_and_continuity_are_operator_readable - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.conversation_routes_keep_runtime_and_recovery_liveview_owned - # covers: architecture.source_code_graph_product_adoption.managed_repo_routes_host_semantic_inspection - # covers: architecture.memory_graph_product_adoption.managed_repo_routes_host_memory_and_provenance_inspection - # covers: architecture.memory_graph_product_adoption.memory_and_provenance_views_can_cross_link_to_source_code - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Projects.Project - - test "61.6.1 repo detail keeps route-owned families on one canonical managed-repository route", %{ - conn: _conn - } do - register_owner("phase61-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase61-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "phase-61-repo-detail", - github_full_name: "owner/phase-61-repo-detail", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - assert has_element?(view, "#project-detail-section-nav-overview[aria-current='page']") - assert has_element?(view, "#project-detail-overview-panel") - refute has_element?(view, "#project-detail-conversation-panel") - - view - |> element("#project-detail-overview-open-conversations") - |> render_click() - - assert_patch(view, ~p"/repos/#{managed_repo_id}?section=conversations") - assert has_element?(view, "#project-detail-section-nav-conversations[aria-current='page']") - assert has_element?(view, "#project-detail-conversation-panel") - assert has_element?(view, "#project-detail-conversation-workspace-summary") - assert has_element?(view, "#project-detail-conversation-runtime-status", "Ready") - refute has_element?(view, "#project-detail-overview-panel") - - view - |> element("#project-detail-section-nav-semantic") - |> render_click() - - assert_patch(view, ~p"/repos/#{managed_repo_id}?section=semantic") - assert has_element?(view, "#project-detail-semantic-inspection") - refute has_element?(view, "#project-detail-conversation-panel") - - view - |> element("#project-detail-semantic-open-memory") - |> render_click() - - assert_patch(view, ~p"/repos/#{managed_repo_id}?section=memory") - assert has_element?(view, "#project-detail-memory-inspection") - refute has_element?(view, "#project-detail-semantic-inspection") - - view - |> element("#project-detail-memory-open-semantic") - |> render_click() - - assert_patch(view, ~p"/repos/#{managed_repo_id}?section=semantic") - assert has_element?(view, "#project-detail-semantic-inspection") - - view - |> element("#project-detail-section-nav-workflows") - |> render_click() - - assert_patch(view, ~p"/repos/#{managed_repo_id}?section=workflows") - assert has_element?(view, "#project-detail-workflows-panel") - assert has_element?(view, "#project-detail-workflow-readiness-summary") - refute has_element?(view, "#project-detail-memory-inspection") - end - - @tag skip: "repo-local .spec workspace was removed" - test "61.6.2 phase 61 plan, specs, and browser coverage remain aligned" do - phase_plan = - repo_file!(".planning/phase-61-managed-repo-detail-sidebar-information-architecture.md") - - factory_spec = repo_file!(".spec/specs/factory_control_plane.spec.md") - conversation_spec = repo_file!(".spec/specs/conversation_orchestration.spec.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - memory_spec = repo_file!(".spec/specs/memory_graph_product_adoption.spec.md") - source_spec = repo_file!(".spec/specs/source_code_graph_product_adoption.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - conversation_browser = repo_file!("test/e2e/conversation-ui.spec.ts") - - assert phase_plan =~ "[x] 61 Phase 61 - Managed Repo Detail Sidebar Information Architecture" - assert phase_plan =~ "[x] 61.1 Section - Canonical Tab Families And Route Ownership" - assert phase_plan =~ "[x] 61.2 Section - Sidebar Shell And Shared Navigation Language" - assert phase_plan =~ "[x] 61.3 Section - Overview And Workflow Separation" - assert phase_plan =~ "[x] 61.4 Section - Conversation And Knowledge Surface Compartmentalization" - assert phase_plan =~ "[x] 61.5 Section - Current-Truth And Helper Convergence" - assert phase_plan =~ "[x] 61.6 Section - Phase Integration Tests" - - assert factory_spec =~ "route-owned families" - assert factory_spec =~ "test/jido_code_web/live/phase_sixty_one_integration_test.exs" - - assert conversation_spec =~ "route-owned `Conversations` family" - assert conversation_spec =~ "test/jido_code_web/live/phase_sixty_one_integration_test.exs" - - assert frontend_spec =~ "route-selected overview, conversations, semantic, memory, and workflows families" - assert frontend_spec =~ "test/jido_code_web/live/phase_sixty_one_integration_test.exs" - - assert memory_spec =~ "route-owned `Memory` family" - assert memory_spec =~ "test/jido_code_web/live/phase_sixty_one_integration_test.exs" - - assert source_spec =~ "route-owned `Semantic` family" - assert source_spec =~ "test/jido_code_web/live/phase_sixty_one_integration_test.exs" - - assert package_spec =~ ".planning/phase-61-managed-repo-detail-sidebar-information-architecture.md" - assert package_spec =~ "test/jido_code_web/live/phase_sixty_one_integration_test.exs" - - assert conversation_browser =~ - "repo detail keeps desktop sidebar family navigation on the left while panels switch in place" - - assert conversation_browser =~ - "repo detail keeps narrow-screen family navigation usable as a horizontal fallback rail" - end - - defp create_workspace_path! do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_phase_sixty_one_live_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_seven_integration_test.exs b/test/jido_code_web/live/phase_sixty_seven_integration_test.exs deleted file mode 100644 index 7e740dc5..00000000 --- a/test/jido_code_web/live/phase_sixty_seven_integration_test.exs +++ /dev/null @@ -1,317 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtySevenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_memory_summaries_remain_bounded_and_action_oriented - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.Actor - alias JidoCode.Control.RepoBridge - alias JidoCode.Orchestration.{Run, WorkflowRun} - alias JidoCode.Projects.Project - alias JidoCode.Repo - - setup do - original_runtime_loader = - Application.get_env(:jido_code, :dashboard_runtime_evidence_loader, :__missing__) - - original_memory_loader = - Application.get_env(:jido_code, :dashboard_memory_summary_loader, :__missing__) - - original_conversation_loader = - Application.get_env(:jido_code, :dashboard_conversation_summary_loader, :__missing__) - - on_exit(fn -> - restore_env(:dashboard_runtime_evidence_loader, original_runtime_loader) - restore_env(:dashboard_memory_summary_loader, original_memory_loader) - restore_env(:dashboard_conversation_summary_loader, original_conversation_loader) - end) - - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - :ok - end - - test "67.4.1 dashboard overview renders split monitoring panels with bounded accordions", %{ - conn: _conn - } do - register_owner("phase67-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase67-owner@example.com", "owner-password-123") - - %{route_id: route_id, managed_repo: managed_repo} = - provision_managed_repo!(%{ - name: "repo-phase67", - github_full_name: "owner/repo-phase67", - default_branch: "main", - settings: %{} - }) - - now = DateTime.utc_now() |> DateTime.truncate(:second) - - {:ok, _run} = - create_run(route_id, "phase67-run", DateTime.add(now, -600, :second), %{ - status: :awaiting_approval, - current_step: "publish_pr", - current_stage: "approval" - }) - - Application.put_env(:jido_code, :dashboard_conversation_summary_loader, fn -> - {:ok, - [ - %{ - id: "phase67-conversation-summary", - route_id: route_id, - managed_repo_id: managed_repo.id, - repo_label: "owner/repo-phase67", - role_scope: "work_item_scoped", - role_attachment_mode: "governed_work", - role_work_item_id: "work-item-phase67", - latest_status: "active", - active_count: 2, - clarification_count: 1, - detail: "2 governed conversations active. 1 clarification turn needs an answer.", - latest_activity_at: now, - route: "/repos/#{route_id}#project-detail-conversation-panel", - action_label: "Open governed supervision" - } - ], nil} - end) - - Application.put_env(:jido_code, :dashboard_memory_summary_loader, fn -> - {:ok, - [ - %{ - id: "phase67-memory-summary", - route_id: route_id, - managed_repo_id: managed_repo.id, - repo_label: "owner/repo-phase67", - state: "invalidated", - label: "Memory graph invalidated", - detail: "Repository memory graph validation was explicitly invalidated.", - remediation: "Validate repository memory graph for the current revision.", - memory_count: 4, - provenance_count: 3, - route: "/repos/#{route_id}#project-detail-memory-inspection", - action_label: "Validate memory graph", - action_needed?: true - } - ], nil} - end) - - Application.put_env(:jido_code, :dashboard_runtime_evidence_loader, fn -> - {:ok, - [ - %{ - id: "phase67-runtime-summary", - managed_repo_id: managed_repo.id, - repo_label: "owner/repo-phase67", - status: "blocked", - summary: "Runtime delivery is blocked until repository workspace binding is repaired.", - delivery_mode: "guided", - reason_code: "workspace_binding_missing", - latest_provider: "github", - supervision_mode: "guided", - review_required: true, - updated_at: now - } - ], nil} - end) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?onboarding=completed", on_error: :warn) - - repo_dom_token = run_dom_token("dashboard-repository-monitoring-#{route_id}") - - assert has_element?(view, "#dashboard-root[data-dashboard-section='overview']") - assert has_element?(view, "#dashboard-overview-repository-card-#{repo_dom_token}") - assert has_element?(view, "#dashboard-overview-repository-accordion-shell-#{repo_dom_token}") - - refute has_element?(view, "#dashboard-overview-repository-accordion-shell-#{repo_dom_token}[open]") - - view - |> element("#dashboard-overview-repository-accordion-toggle-#{repo_dom_token}") - |> render_click() - - assert has_element?(view, "#dashboard-root[data-dashboard-section='overview']") - assert has_element?(view, "#dashboard-overview-repository-accordion-shell-#{repo_dom_token}[open]") - assert has_element?(view, "#dashboard-overview-repository-accordion-panel-#{repo_dom_token}") - assert has_element?(view, "#dashboard-overview-repository-detail-run-#{repo_dom_token}") - assert has_element?(view, "#dashboard-overview-repository-detail-conversations-#{repo_dom_token}") - assert has_element?(view, "#dashboard-overview-repository-detail-memory-#{repo_dom_token}") - assert has_element?(view, "#dashboard-overview-repository-detail-runtime-#{repo_dom_token}") - - assert has_element?( - view, - "#dashboard-overview-repository-detail-run-run-link-#{repo_dom_token}[href=\"/repos/#{route_id}/runs/phase67-run\"]", - "Open governed run" - ) - - assert has_element?( - view, - "#dashboard-overview-repository-detail-conversations-conversation-link-#{repo_dom_token}[href=\"/repos/#{route_id}#project-detail-conversation-panel\"]", - "Open governed supervision" - ) - - assert has_element?( - view, - "#dashboard-overview-repository-detail-memory-memory-link-#{repo_dom_token}[href=\"/repos/#{route_id}#project-detail-memory-inspection\"]", - "Validate memory graph" - ) - - assert has_element?( - view, - "#dashboard-overview-repository-detail-runtime-#{repo_dom_token} a[href=\"/settings/auth\"]", - "Open settings" - ) - end - - @tag skip: "repo-local .spec workspace was removed" - test "67.4.2 phase 67 plan, ADR, specs, and browser coverage remain aligned" do - phase_plan = - repo_file!(".planning/phase-67-dashboard-repository-panels-and-accordion-monitoring.md") - - concern_adr = - repo_file!(".spec/decisions/jido_code.dashboard_concern_tabs_and_overview_handoff.md") - - monitoring_adr = - repo_file!(".spec/decisions/jido_code.dashboard_developer_centric_monitoring_sidebar.md") - - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - factory_spec = repo_file!(".spec/specs/factory_control_plane.spec.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - conversation_spec = repo_file!(".spec/specs/conversation_orchestration.spec.md") - memory_spec = repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") - repo_posture_spec = repo_file!(".spec/specs/repo_posture.spec.md") - runtime_spec = repo_file!(".spec/specs/runtime_service_overlay.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - browser_spec = repo_file!("test/e2e/dashboard-tabs.spec.ts") - - assert phase_plan =~ "[x] 67.1 Section - Split Repository Panel Composition" - assert phase_plan =~ "[x] 67.2 Section - Accordion Detail And Bounded Concern Handoff" - assert phase_plan =~ "[x] 67.3 Section - Current-Truth And Helper Convergence" - - assert concern_adr =~ "route-owned `section` selection" - assert concern_adr =~ "left-sidebar" - - assert monitoring_adr =~ "Phase 67 has now landed" - assert monitoring_adr =~ "borderless monitoring panel" - assert monitoring_adr =~ "bounded LiveView-owned accordion region" - - assert baseline_spec =~ "split monitoring panels plus bounded in-place accordions" - assert factory_spec =~ "bounded per-repository accordions" - assert frontend_spec =~ "split monitoring panels plus LiveView-owned accordions" - assert conversation_spec =~ "overview accordion" - assert memory_spec =~ "overview accordions" - assert repo_posture_spec =~ "overview's bounded in-place monitoring accordions" - assert runtime_spec =~ "overview accordions" - assert package_spec =~ "phase-67 split-panel accordion monitoring convergence" - - assert browser_spec =~ - "dashboard overview repository panels expand bounded monitoring detail on wide screens" - - assert browser_spec =~ - "dashboard overview repository accordions stay usable on narrow screens" - end - - defp create_run(repo_identifier, run_id, started_at, attrs) do - {:ok, repo_scope} = RepoBridge.repo_scope(repo_identifier) - - {:ok, project} = - Project.create( - %{ - name: "phase67-#{run_id}", - source_kind: :local, - local_path: "/tmp/phase67-#{run_id}" - }, - actor: Actor.operator_actor() - ) - - workflow_attrs = - %{ - project_id: project.id, - managed_repo_id: repo_scope.managed_repo.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render dashboard monitoring accordion"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "phase67-owner@example.com"}, - current_step: "queued", - started_at: started_at - } - - {:ok, workflow_run} = WorkflowRun.create(workflow_attrs, actor: Actor.operator_actor()) - - final_workflow_run = - workflow_run - |> maybe_transition_to_running(attrs, started_at) - |> maybe_transition_to_terminal(attrs, started_at) - - Run.get_by_workflow_run_id(final_workflow_run.id, actor: Actor.operator_actor()) - end - - defp maybe_transition_to_running(workflow_run, attrs, started_at) do - desired_status = Map.get(attrs, :status, :pending) - - if desired_status in [:running, :awaiting_approval, :completed, :failed, :cancelled] do - {:ok, workflow_run} = - WorkflowRun.transition_status( - workflow_run, - %{ - to_status: :running, - current_step: Map.get(attrs, :current_step, "queued"), - transitioned_at: DateTime.add(started_at, 1, :second) - }, - actor: Actor.operator_actor() - ) - - workflow_run - else - workflow_run - end - end - - defp maybe_transition_to_terminal(%WorkflowRun{} = workflow_run, attrs, started_at) do - case Map.get(attrs, :status, :pending) do - status when status in [:awaiting_approval, :completed, :failed, :cancelled] -> - {:ok, workflow_run} = - WorkflowRun.transition_status( - workflow_run, - %{ - to_status: status, - current_step: Map.get(attrs, :current_step, workflow_run.current_step), - transitioned_at: Map.get(attrs, :completed_at) || DateTime.add(started_at, 60, :second) - }, - actor: Actor.operator_actor() - ) - - workflow_run - - _other -> - workflow_run - end - end - - defp run_dom_token(value) do - value - |> to_string() - |> String.replace(~r/[^a-zA-Z0-9_-]/, "-") - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_six_integration_test.exs b/test/jido_code_web/live/phase_sixty_six_integration_test.exs deleted file mode 100644 index 6827535e..00000000 --- a/test/jido_code_web/live/phase_sixty_six_integration_test.exs +++ /dev/null @@ -1,257 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtySixIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_memory_summaries_remain_bounded_and_action_oriented - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.Actor - alias JidoCode.Control.RepoBridge - alias JidoCode.Orchestration.{Run, WorkflowRun} - alias JidoCode.Projects.Project - alias JidoCode.Repo - - setup do - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - :ok - end - - test "66.3.1 dashboard keeps one route while overview becomes a sidebar-led repository monitoring list", - %{conn: _conn} do - register_owner("phase66-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase66-owner@example.com", "owner-password-123") - - %{route_id: older_route_id} = - provision_managed_repo!(%{ - name: "repo-phase66-older", - github_full_name: "owner/repo-phase66-older", - default_branch: "main", - settings: %{} - }) - - %{route_id: newer_route_id} = - provision_managed_repo!(%{ - name: "repo-phase66-newer", - github_full_name: "owner/repo-phase66-newer", - default_branch: "main", - settings: %{} - }) - - now = DateTime.utc_now() |> DateTime.truncate(:second) - - {:ok, _older_run} = - create_run(older_route_id, "phase66-older-run", DateTime.add(now, -6_000, :second), %{ - status: :completed, - current_step: "publish_pr", - current_stage: "publish_pr", - completed_at: DateTime.add(now, -5_940, :second) - }) - - {:ok, _newer_run} = - create_run(newer_route_id, "phase66-newer-run", DateTime.add(now, -600, :second), %{ - status: :running, - current_step: "implement", - current_stage: "implement" - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/dashboard?onboarding=completed", on_error: :warn) - - html = render(view) - - assert has_element?(view, "#dashboard-root[data-dashboard-section='overview']") - assert has_element?(view, "#dashboard-sidebar-shell") - assert has_element?(view, "#dashboard-section-nav-next_steps") - assert has_element?(view, "#dashboard-overview-repository-list", "owner/repo-phase66-newer") - assert has_element?(view, "#dashboard-overview-repository-list", "Latest run: implement_task running") - - assert string_position(html, "owner/repo-phase66-newer") < - string_position(html, "owner/repo-phase66-older") - - view - |> element("#dashboard-section-nav-runtime") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=runtime") - assert has_element?(view, "#dashboard-root[data-dashboard-section='runtime']") - assert has_element?(view, "#dashboard-runtime-evidence") - refute has_element?(view, "#dashboard-overview-panel") - - view - |> element("#dashboard-section-nav-overview") - |> render_click() - - assert_patch(view, "/dashboard?onboarding=completed§ion=overview") - assert has_element?(view, "#dashboard-overview-panel") - assert has_element?(view, "#dashboard-overview-repository-list") - end - - @tag skip: "repo-local .spec workspace was removed" - test "66.3.2 phase 66 plan, ADRs, specs, and browser coverage remain aligned" do - phase_plan = - repo_file!(".planning/phase-66-dashboard-sidebar-and-repository-monitoring-foundation.md") - - concern_adr = repo_file!(".spec/decisions/jido_code.dashboard_concern_tabs_and_overview_handoff.md") - monitoring_adr = repo_file!(".spec/decisions/jido_code.dashboard_developer_centric_monitoring_sidebar.md") - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - factory_spec = repo_file!(".spec/specs/factory_control_plane.spec.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - conversation_spec = repo_file!(".spec/specs/conversation_orchestration.spec.md") - memory_spec = repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") - repo_posture_spec = repo_file!(".spec/specs/repo_posture.spec.md") - runtime_spec = repo_file!(".spec/specs/runtime_service_overlay.spec.md") - package_spec = repo_file!(".spec/specs/package.spec.md") - browser_spec = repo_file!("test/e2e/dashboard-tabs.spec.ts") - - assert phase_plan =~ "[x] 66 Phase 66 - Dashboard Sidebar And Repository Monitoring Foundation" - assert phase_plan =~ "[x] 66.1 Section - Sidebar Navigation And Route Ownership" - assert phase_plan =~ "[x] 66.2 Section - Repository-First Overview Feed Foundation" - assert phase_plan =~ "[x] 66.3 Section - Phase Integration Tests" - - assert concern_adr =~ "This decision is now landed in product code." - assert concern_adr =~ "evolved from a top rail into left-sidebar" - - assert monitoring_adr =~ "Phase 66 has landed" - assert monitoring_adr =~ "left sidebar that acts as the canonical tab rail" - assert monitoring_adr =~ "repository-first" - assert monitoring_adr =~ "monitoring feed that orders overview entries" - - assert baseline_spec =~ "left-sidebar concern navigation" - assert baseline_spec =~ "repository-first overview" - - assert factory_spec =~ "left-sidebar concern navigation" - assert factory_spec =~ "repository-first monitoring overview" - assert factory_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - - assert frontend_spec =~ "left-sidebar concern navigation" - assert frontend_spec =~ "repository-first overview" - assert frontend_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - assert frontend_spec =~ "test/e2e/dashboard-tabs.spec.ts" - - assert conversation_spec =~ "sidebar-selected dashboard conversation concern" - assert conversation_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - - assert memory_spec =~ "sidebar-selected dashboard memory concern" - assert memory_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - - assert repo_posture_spec =~ "sidebar-selected runtime and posture concern" - assert repo_posture_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - - assert runtime_spec =~ "left-sidebar runtime concern" - assert runtime_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - - assert package_spec =~ ".planning/phase-66-dashboard-sidebar-and-repository-monitoring-foundation.md" - assert package_spec =~ "test/jido_code_web/live/phase_sixty_six_integration_test.exs" - assert package_spec =~ "test/e2e/dashboard-tabs.spec.ts" - - assert browser_spec =~ - "dashboard sidebar keeps the ready-state landing route scanable on wide screens" - - assert browser_spec =~ - "dashboard sidebar navigation stays usable as a wrapped fallback on narrow screens" - end - - defp create_run(repo_identifier, run_id, started_at, attrs) do - {:ok, repo_scope} = RepoBridge.repo_scope(repo_identifier) - - {:ok, project} = - Project.create( - %{ - name: "phase66-#{run_id}", - source_kind: :local, - local_path: "/tmp/phase66-#{run_id}" - }, - actor: Actor.operator_actor() - ) - - workflow_attrs = - %{ - project_id: project.id, - managed_repo_id: repo_scope.managed_repo.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render dashboard monitoring order"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "phase66-owner@example.com"}, - current_step: "queued", - started_at: started_at - } - - {:ok, workflow_run} = WorkflowRun.create(workflow_attrs, actor: Actor.operator_actor()) - - final_workflow_run = - workflow_run - |> maybe_transition_to_running(attrs, started_at) - |> maybe_transition_to_terminal(attrs, started_at) - - Run.get_by_workflow_run_id(final_workflow_run.id, actor: Actor.operator_actor()) - end - - defp maybe_transition_to_running(workflow_run, attrs, started_at) do - desired_status = Map.get(attrs, :status, :pending) - - if desired_status in [:running, :awaiting_approval, :completed, :failed, :cancelled] do - {:ok, workflow_run} = - WorkflowRun.transition_status( - workflow_run, - %{ - to_status: :running, - current_step: Map.get(attrs, :current_step, "queued"), - transitioned_at: DateTime.add(started_at, 1, :second) - }, - actor: Actor.operator_actor() - ) - - workflow_run - else - workflow_run - end - end - - defp maybe_transition_to_terminal(%WorkflowRun{} = workflow_run, attrs, started_at) do - case Map.get(attrs, :status, :pending) do - status when status in [:awaiting_approval, :completed, :failed, :cancelled] -> - {:ok, workflow_run} = - WorkflowRun.transition_status( - workflow_run, - %{ - to_status: status, - current_step: Map.get(attrs, :current_step, workflow_run.current_step), - transitioned_at: Map.get(attrs, :completed_at) || DateTime.add(started_at, 60, :second) - }, - actor: Actor.operator_actor() - ) - - workflow_run - - _other -> - workflow_run - end - end - - defp string_position(haystack, needle) do - case :binary.match(haystack, needle) do - {position, _length} -> position - :nomatch -> flunk("expected #{inspect(needle)} to appear in rendered output") - end - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_sixty_three_integration_test.exs b/test/jido_code_web/live/phase_sixty_three_integration_test.exs deleted file mode 100644 index cf818ccc..00000000 --- a/test/jido_code_web/live/phase_sixty_three_integration_test.exs +++ /dev/null @@ -1,280 +0,0 @@ -defmodule JidoCodeWeb.PhaseSixtyThreeIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: setup.onboarding.runtime_defaults_seed_repo_scoped_workspace_binding - # covers: architecture.factory_control_plane.managed_repos_own_repo_scoped_workspace_binding - # covers: architecture.conversation_orchestration.runtime_readiness_uses_managed_repo_workspace_binding - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Projects.Project - alias JidoCode.Workbench.ProjectDetail - - @managed_env_keys [:system_config] - - setup do - original_env = - Enum.map(@managed_env_keys, fn key -> - {key, Application.get_env(:jido_code, key, :__missing__)} - end) - - on_exit(fn -> - Enum.each(original_env, fn {key, value} -> - restore_env(key, value) - end) - end) - - :ok - end - - test "63.3.1 repo detail repair rebinds only the selected managed repository", %{conn: _conn} do - register_owner("phase63-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase63-owner@example.com", "owner-password-123") - - default_root = create_workspace_path!("phase63-default-root") - repaired_workspace_path = create_workspace_path!("phase63-repaired-repo") - sibling_workspace_path = create_workspace_path!("phase63-sibling-repo") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: true, - onboarding_step: 3, - onboarding_state: %{}, - default_environment: :local, - workspace_root: default_root - }) - - {:ok, repair_target} = - Project.create(%{ - name: "phase-63-repair-target", - github_full_name: "owner/phase-63-repair-target", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - {:ok, sibling_repo} = - Project.create(%{ - name: "phase-63-sibling-repo", - github_full_name: "owner/phase-63-sibling-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => sibling_workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{repair_target.id}?section=conversations", on_error: :warn) - - assert has_element?(view, "#project-detail-conversation-runtime-status", "Blocked") - - view - |> element("#project-detail-conversation-runtime-repair") - |> render_click() - - view - |> form("#project-detail-workspace-binding-form", %{ - "workspace_binding" => %{ - "workspace_environment" => "local", - "workspace_path" => repaired_workspace_path - } - }) - |> render_submit() - - view - |> element("#project-detail-overview-open-conversations") - |> render_click() - - assert has_element?(view, "#project-detail-conversation-runtime-status", "Ready") - assert has_element?(view, "#project-detail-conversation-runtime-workspace", repaired_workspace_path) - - {:ok, repaired_detail} = ProjectDetail.load(managed_repo_route_id!(repair_target.id)) - {:ok, sibling_detail} = ProjectDetail.load(managed_repo_route_id!(sibling_repo.id)) - - assert ProjectDetail.workspace_path(repaired_detail) == repaired_workspace_path - - assert repaired_detail.settings["workspace"]["workspace_root"] == - Path.dirname(repaired_workspace_path) - - assert ProjectDetail.workspace_path(sibling_detail) == sibling_workspace_path - assert Application.get_env(:jido_code, :system_config).workspace_root == default_root - end - - test "63.3.2 setup runtime defaults remain seed-only metadata after repo detail repair adoption", %{ - conn: conn - } do - register_owner("phase63-setup-owner@example.com", "owner-password-123") - - existing_repo_path = create_workspace_path!("phase63-existing-repo") - new_default_root = create_workspace_path!("phase63-new-default-root") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "phase63-setup-owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - }, - default_environment: :sprite, - workspace_root: nil - }) - - {:ok, project} = - Project.create(%{ - name: "phase-63-seed-only", - github_full_name: "owner/phase-63-seed-only", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => existing_repo_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, setup_view, _html} = - conn - |> authenticate_owner_conn("phase63-setup-owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - render_change(setup_view, "change_runtime_environment", %{ - "runtime_environment" => %{"mode" => "local"} - }) - - render_submit(setup_view, "save_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "local", - "workspace_root" => new_default_root - } - }) - - assert vue(setup_view, id: "setup-runtime-defaults-widget").props["savedRuntimeNote"] == - "New local imports will seed repo workspace paths from #{new_default_root} until each managed repository is rebound." - - {:ok, detail} = ProjectDetail.load(managed_repo_route_id!(project.id)) - assert ProjectDetail.workspace_path(detail) == existing_repo_path - refute ProjectDetail.workspace_path(detail) == new_default_root - assert Application.get_env(:jido_code, :system_config).workspace_root == new_default_root - end - - @tag skip: "repo-local .spec workspace was removed" - test "63.3.3 phase 63 plan, ADR, specs, and integration coverage remain aligned" do - phase_plan = - repo_file!(".planning/phase-63-repo-scoped-workspace-configuration-surfaces.md") - - adr = repo_file!(".spec/decisions/jido_code.managed_repo_workspace_binding_is_repo_scoped.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - conversation_spec = repo_file!(".spec/specs/conversation_orchestration.spec.md") - factory_spec = repo_file!(".spec/specs/factory_control_plane.spec.md") - baseline_spec = repo_file!(".spec/specs/baseline_surface.spec.md") - setup_spec = repo_file!(".spec/specs/setup_onboarding.spec.md") - user_admin_spec = repo_file!(".spec/specs/user_administration.spec.md") - source_spec = repo_file!(".spec/specs/source_code_graph_product_adoption.spec.md") - memory_spec = repo_file!(".spec/specs/memory_graph_product_adoption.spec.md") - - memory_rollout_spec = - repo_file!(".spec/specs/memory_graph_surface_rollout_and_governance_actions.spec.md") - - memory_expansion_spec = - repo_file!(".spec/specs/memory_graph_workflow_and_operator_expansion.spec.md") - - package_spec = repo_file!(".spec/specs/package.spec.md") - - assert phase_plan =~ "[x] 63 Phase 63 - Repo-Scoped Workspace Configuration Surfaces" - assert phase_plan =~ "[x] 63.1 Section - Repo-Scoped Workspace Mutation Boundaries" - assert phase_plan =~ "[x] 63.2 Section - Operator And Setup Surface Adoption" - assert phase_plan =~ "[x] 63.3 Section - Phase Integration Tests" - - assert adr =~ "repo detail" - assert adr =~ "seed metadata" - - assert frontend_spec =~ "repo-scoped workspace-binding readiness and repair" - assert frontend_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert conversation_spec =~ "workspace-readiness repair" - assert conversation_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert factory_spec =~ "workspace inspection and repair" - assert factory_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert baseline_spec =~ "seed metadata" - assert baseline_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert setup_spec =~ "seed context" - assert setup_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert user_admin_spec =~ "runtime defaults as seed metadata" - assert user_admin_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert source_spec =~ "blocked semantic states on repo detail" - assert source_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert memory_spec =~ "blocked memory states on repo detail" - assert memory_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert memory_rollout_spec =~ "reachable from blocked graph-backed surfaces" - assert memory_rollout_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert memory_expansion_spec =~ "reachable from blocked graph-backed operator states" - assert memory_expansion_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - - assert package_spec =~ "repo-detail repair surface" - assert package_spec =~ "test/jido_code_web/live/phase_sixty_three_integration_test.exs" - end - - defp create_workspace_path!(prefix) do - workspace_path = - Path.join( - System.tmp_dir!(), - "#{prefix}-#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_thirteen_integration_test.exs b/test/jido_code_web/live/phase_thirteen_integration_test.exs deleted file mode 100644 index 421097cb..00000000 --- a/test/jido_code_web/live/phase_thirteen_integration_test.exs +++ /dev/null @@ -1,60 +0,0 @@ -defmodule JidoCodeWeb.PhaseThirteenIntegrationTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.live_vue_is_canonical_rich_component_bridge - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.testing_keeps_liveview_and_adds_live_vue_aware_helpers - use JidoCodeWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - alias Phoenix.LiveView.JS - - test "hybrid surface mounts through the shared boundary with bounded props and handlers", %{conn: conn} do - {:ok, view, _html} = live_isolated(conn, JidoCodeWeb.TestSupport.LiveVueBoundaryLive) - - assert has_element?(view, "#boundary-probe") - - vue = assert_vue_component(view, "BoundaryProbe", id: "boundary-probe") - - assert vue.props["status"] == "idle" - assert is_map(vue.props["taskForm"]) - assert is_map(vue.props["uploadConfig"]) - assert_vue_handler(view, "request-sync", JS.push("request_sync", value: %{origin: "vue"}), id: "boundary-probe") - assert_vue_handler(view, "simulate-failure", "simulate_failure", id: "boundary-probe") - end - - test "server refresh updates props and stream diffs without giving up liveview ownership", %{conn: conn} do - {:ok, view, _html} = live_isolated(conn, JidoCodeWeb.TestSupport.LiveVueBoundaryLive) - - render_click(element(view, "#server-sync")) - - vue = vue(view, id: "boundary-probe") - - assert vue.props["status"] == "synced" - - assert Enum.any?(vue.streams_diff, fn - ["upsert", "/items/-", %{"label" => "Server refresh"}] -> true - _ -> false - end) - end - - test "typed server failures surface as bounded vue props", %{conn: conn} do - {:ok, view, _html} = live_isolated(conn, JidoCodeWeb.TestSupport.LiveVueBoundaryLive) - - render_click(element(view, "#server-failure")) - - vue = vue(view, id: "boundary-probe") - - assert vue.props["failure"] == %{"kind" => "validation", "message" => "Need a title"} - end - - test "plain liveview routes keep using the normal test path", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/welcome") - - assert has_element?(view, "#system-check") - refute render(view) =~ ~s(phx-hook="VueHook") - end -end diff --git a/test/jido_code_web/live/phase_thirty_eight_integration_test.exs b/test/jido_code_web/live/phase_thirty_eight_integration_test.exs deleted file mode 100644 index 980f07c7..00000000 --- a/test/jido_code_web/live/phase_thirty_eight_integration_test.exs +++ /dev/null @@ -1,598 +0,0 @@ -defmodule JidoCodeWeb.PhaseThirtyEightIntegrationTest do - # covers: architecture.memory_graph_product_adoption.managed_repo_routes_host_memory_and_provenance_inspection - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.memory_graph_workflow_and_operator_expansion.governed_surfaces_host_memory_context - # covers: architecture.run_governance.run_detail_can_host_bounded_memory_context - # covers: package.jido_code.version_controlled_quality_surfaces - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Governance.{Decision, Evidence} - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope} - alias JidoCode.Operations.WorkItem - alias JidoCode.Orchestration.WorkflowRun - alias JidoCode.Projects.Project - - setup do - previous_memory = Application.get_env(:jido_code, :memory_graph_enabled, false) - previous_source = Application.get_env(:jido_code, :source_code_graph_enabled, false) - - Application.put_env(:jido_code, :memory_graph_enabled, true) - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, previous_memory) - Application.put_env(:jido_code, :source_code_graph_enabled, previous_source) - end) - - :ok - end - - test "38.3.1.1 repo detail, run detail, and workbench render governed memory context without generic artifact contracts", - %{conn: _conn} do - register_owner("phase38-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase38-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!("ExamplePhaseThirtyEightLive") - - {:ok, project} = - Project.create(%{ - name: "phase-38-memory-surfaces", - github_full_name: "owner/phase-38-memory-surfaces", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, work_item} = - WorkItem.create( - %{ - managed_repo_id: managed_repo.id, - summary: "Review governed memory context", - status: :open, - category: "review_follow_up" - }, - actor: Actor.operator_actor() - ) - - run_id = "run-phase-38-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render Phase 38 surfaces"}, - input_metadata: %{"task_summary" => %{required: true, source: "phase_thirty_eight_live"}}, - initiating_actor: %{id: "owner-38", email: "phase38-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-12 15:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "review_memory", - transitioned_at: ~U[2026-04-12 15:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-12 15:02:00Z] - }) - - {:ok, run} = - JidoCode.Orchestration.Run.get_by_managed_repo_and_run_id( - managed_repo.id, - run_id, - actor: Actor.operator_actor() - ) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - key: "phase_38_memory", - evidence_type: "memory_graph_finding", - summary: "Phase 38 should keep governed memory context visible.", - evidence_details: %{"source" => "phase_thirty_eight_live"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "phase-38-run-#{run.id}", - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - decision: :defer, - actor: %{"id" => "owner-38", "email" => "phase38-owner@example.com"}, - rationale: "Governed memory context should stay reviewable on current product surfaces.", - decision_metadata: %{"source" => "phase_thirty_eight_live"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - revision = revision_metadata.current_revision - - seed_repo_detail_memory!( - managed_repo.id, - workspace_path, - revision, - run_id, - work_item.id - ) - - seed_run_memory_context!( - managed_repo.id, - workspace_path, - revision, - run_id, - evidence.id, - decision.id, - work_item_id: work_item.id - ) - - {:ok, repo_view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - repo_rendered = render(repo_view) - - assert has_element?(repo_view, "#project-detail-memory-inspection") - assert repo_rendered =~ "Governed context" - assert repo_rendered =~ "Run #{run_id}" - assert repo_rendered =~ "Work item #{work_item.id}" - refute repo_rendered =~ "/artifact/" - refute repo_rendered =~ "Evidence Artifact" - - {:ok, run_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - run_rendered = render(run_view) - - assert has_element?(run_view, "#run-detail-memory-context") - assert has_element?(run_view, "#run-detail-memory-1-governed-label", "Governed context") - - assert has_element?( - run_view, - "#run-detail-evidence-memory-#{evidence.id}-memory-1-governed-label", - "Governed context" - ) - - assert run_rendered =~ "Open governed record" - refute run_rendered =~ "/artifact/" - refute run_rendered =~ "Evidence Artifact" - - {:ok, workbench_view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - workbench_rendered = render(workbench_view) - - assert has_element?(workbench_view, "#workbench-project-memory-hint-#{managed_repo.id}") - - assert has_element?( - workbench_view, - "#workbench-project-memory-hint-badge-#{managed_repo.id}", - "Memory graph ready" - ) - - assert workbench_rendered =~ "bounded recall and capture" - refute workbench_rendered =~ "/artifact/" - end - - test "38.3.1.2 invalidated and rebuild-required surfaces stay explainable through product-owned recovery", - %{conn: _conn} do - register_owner("phase38-recovery-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase38-recovery-owner@example.com", "owner-password-123") - - invalidated_workspace = create_workspace_path!("ExamplePhaseThirtyEightInvalidated") - - {:ok, invalidated_project} = - Project.create(%{ - name: "phase-38-invalidated-run", - github_full_name: "owner/phase-38-invalidated-run", - default_branch: "main", - settings: %{workspace: %{workspace_path: invalidated_workspace}} - }) - - {:ok, invalidated_repo} = - ManagedRepo.get_by_legacy_project_id(invalidated_project.id, actor: Actor.operator_actor()) - - {:ok, invalidated_work_item} = - WorkItem.create( - %{ - managed_repo_id: invalidated_repo.id, - summary: "Recover invalidated run memory", - status: :open, - category: "review_follow_up" - }, - actor: Actor.operator_actor() - ) - - invalidated_run_id = "run-phase-38-invalidated-#{System.unique_integer([:positive])}" - - {:ok, invalidated_workflow_run} = - WorkflowRun.create(%{ - project_id: invalidated_project.id, - run_id: invalidated_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Recover invalidated memory context"}, - input_metadata: %{"task_summary" => %{required: true, source: "phase_thirty_eight_live"}}, - initiating_actor: %{id: "owner-38", email: "phase38-recovery-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-12 16:00:00Z] - }) - - {:ok, invalidated_workflow_run} = - WorkflowRun.transition_status(invalidated_workflow_run, %{ - to_status: :running, - current_step: "review_memory", - transitioned_at: ~U[2026-04-12 16:01:00Z] - }) - - {:ok, _invalidated_workflow_run} = - WorkflowRun.transition_status(invalidated_workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-12 16:02:00Z] - }) - - {:ok, invalidated_run} = - JidoCode.Orchestration.Run.get_by_managed_repo_and_run_id( - invalidated_repo.id, - invalidated_run_id, - actor: Actor.operator_actor() - ) - - {:ok, invalidated_evidence} = - Evidence.create( - %{ - run_id: invalidated_run.id, - managed_repo_id: invalidated_repo.id, - work_item_id: invalidated_work_item.id, - key: "phase_38_invalidated_memory", - evidence_type: "memory_graph_finding", - summary: "Run memory recovery should stay product-owned.", - evidence_details: %{"source" => "phase_thirty_eight_live"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, invalidated_decision} = - Decision.create( - %{ - decision_key: "phase-38-invalidated-run-#{invalidated_run.id}", - run_id: invalidated_run.id, - managed_repo_id: invalidated_repo.id, - work_item_id: invalidated_work_item.id, - decision: :approve, - actor: %{"id" => "owner-38", "email" => "phase38-recovery-owner@example.com"}, - rationale: "Run detail should expose bounded recovery for invalidated memory state.", - decision_metadata: %{"source" => "phase_thirty_eight_live"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, invalidated_revision_metadata} = MemoryGraph.current_revision_metadata(invalidated_workspace) - - seed_run_memory_context!( - invalidated_repo.id, - invalidated_workspace, - invalidated_revision_metadata.current_revision, - invalidated_run_id, - invalidated_evidence.id, - invalidated_decision.id, - work_item_id: invalidated_work_item.id - ) - - assert {:ok, _invalidate_result} = - AgentWorkspace.invalidate_memory_graph( - invalidated_repo.id, - invalidated_workspace, - reason: :manual_invalidation - ) - - {:ok, invalidated_run_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{invalidated_project.id}/runs/#{invalidated_run_id}", - on_error: :warn - ) - - assert has_element?(invalidated_run_view, "#run-detail-memory-context-notice") - assert has_element?(invalidated_run_view, "#run-detail-memory-recover", "Validate memory graph") - - invalidated_run_view - |> element("#run-detail-memory-recover") - |> render_click() - - assert has_element?( - invalidated_run_view, - "#run-detail-memory-action-feedback-type", - "memory_graph_recovered" - ) - - legacy_workspace = create_workspace_path!("ExamplePhaseThirtyEightLegacy") - - {:ok, legacy_project} = - Project.create(%{ - name: "phase-38-legacy-repo", - github_full_name: "owner/phase-38-legacy-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => legacy_workspace, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, legacy_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project.id, actor: Actor.operator_actor()) - - seed_legacy_rebuild_state!(legacy_repo.id, legacy_workspace, "phase-38-legacy-rebuild") - - {:ok, repo_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{legacy_project.id}", on_error: :warn) - - assert has_element?(repo_view, "#project-detail-memory-notice") - assert render(repo_view) =~ "legacy governed artifact links" - assert has_element?(repo_view, "#project-detail-memory-recover", "Recover memory graph") - - repo_view - |> element("#project-detail-memory-recover") - |> render_click() - - assert has_element?(repo_view, "#project-detail-memory-feedback-type", "memory_graph_recovered") - - {:ok, workbench_view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(workbench_view, "#workbench-project-memory-hint-#{legacy_repo.id}") - - assert has_element?( - workbench_view, - "#workbench-project-memory-hint-badge-#{legacy_repo.id}", - "Memory graph failed" - ) - - assert has_element?( - workbench_view, - "#workbench-project-memory-hint-detail-#{legacy_repo.id}", - "legacy governed artifact links" - ) - - assert has_element?( - workbench_view, - "#workbench-project-memory-hint-recovery-#{legacy_repo.id}[href='/repos/#{legacy_repo.id}']", - "Open repo detail to review governed memory context and recover memory graph." - ) - end - - defp create_workspace_path!(module_name) do - workspace_path = - System.tmp_dir!() - |> Path.join("jido_code_phase_thirty_eight_live_#{System.unique_integer([:positive])}") - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule PhaseThirtyEightLive.MixProject do - use Mix.Project - - def project do - [app: :phase_thirty_eight_live, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - File.write!( - Path.join(workspace_path, "lib/example_phase_thirty_eight_live.ex"), - """ - defmodule #{module_name} do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp seed_repo_detail_memory!(managed_repo_id, workspace_path, revision, run_id, work_item_id) do - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "repo-detail-phase-38-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:phase-thirty-eight-live", - workflow: :plan, - work_item_id: work_item_id, - goal: "Seed repo detail memory context" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.decision( - session_id: session_id, - actor_id: "system:phase-thirty-eight-live", - workflow: :review, - work_item_id: work_item_id, - content: "Repo detail should preserve governed memory labels.", - rationale: "Phase 38 should keep governed navigation legible without generic artifact contracts.", - decision_status: :accepted, - revision: revision, - anchors: %{module_name: "ExamplePhaseThirtyEightLive"}, - governed_context: %{run_id: run_id, work_item_id: work_item_id}, - classification: %{ - source: "phase_thirty_eight_live", - reason: "Repo detail coverage needs durable governed memory context." - } - ), - revision: revision - ) - end - - defp seed_run_memory_context!(managed_repo_id, workspace_path, revision, run_id, evidence_id, decision_id, opts) do - work_item_id = Keyword.fetch!(opts, :work_item_id) - - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "run-detail-phase-38-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:phase-thirty-eight-live", - workflow: :review, - work_item_id: work_item_id, - goal: "Seed run detail memory context" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _review_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.review( - session_id: session_id, - actor_id: "system:phase-thirty-eight-live", - workflow: :review, - work_item_id: work_item_id, - content: "Governed run memory context should stay reviewable.", - anchors: %{module_name: "ExamplePhaseThirtyEightLive"}, - governed_context: %{run_id: run_id, work_item_id: work_item_id, decision_id: decision_id} - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.known_issue( - session_id: session_id, - actor_id: "system:phase-thirty-eight-live", - workflow: :review, - work_item_id: work_item_id, - content: "Run detail should show governed memory context.", - revision: revision, - anchors: %{module_name: "ExamplePhaseThirtyEightLive"}, - governed_context: %{ - run_id: run_id, - work_item_id: work_item_id, - evidence_id: evidence_id, - decision_id: decision_id - }, - classification: %{ - source: "phase_thirty_eight_live", - reason: "Run detail coverage needs bounded governed memory context." - } - ), - revision: revision - ) - end - - defp seed_legacy_rebuild_state!(managed_repo_id, workspace_path, revision) do - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - inject_legacy_governed_artifact!(managed_repo_id, workspace_path) - - assert {:ok, _validate_result} = - AgentWorkspace.validate_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - end - - defp inject_legacy_governed_artifact!(managed_repo_id, workspace_path) do - store_path = MemoryGraph.graph_store_path(workspace_path) - legacy_memory_iri = RDF.iri("#{MemoryGraph.base_iri(managed_repo_id)}fact/legacy-governed-artifact") - legacy_artifact_iri = MemoryGraph.artifact_iri(managed_repo_id, "run_id/run-legacy") - - {:ok, store} = TripleStore.open(store_path, create_if_missing: false, schema: :quad) - - try do - graph = - RDF.Graph.new([ - {legacy_memory_iri, RDF.type(), RDF.iri("https://jido.run/ontology/memory#Fact")}, - {legacy_memory_iri, RDF.type(), RDF.iri("https://jido.run/ontology/memory#Memory")}, - {legacy_artifact_iri, RDF.type(), RDF.iri("https://jido.run/ontology/memory#EvidenceArtifact")}, - {legacy_memory_iri, RDF.iri("https://jido.run/ontology/memory#supportedBy"), legacy_artifact_iri} - ]) - - {:ok, _triple_count} = - TripleStore.load_graph(store, graph, graph: MemoryGraph.memory_named_graph_resource()) - after - :ok = TripleStore.close(store) - end - end -end diff --git a/test/jido_code_web/live/phase_thirty_four_integration_test.exs b/test/jido_code_web/live/phase_thirty_four_integration_test.exs deleted file mode 100644 index 23051dfc..00000000 --- a/test/jido_code_web/live/phase_thirty_four_integration_test.exs +++ /dev/null @@ -1,300 +0,0 @@ -defmodule JidoCodeWeb.PhaseThirtyFourIntegrationTest do - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_and_governed_surfaces_host_bounded_memory_context - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.operator_memory_actions_are_available_from_canonical_surfaces - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.dashboard_memory_summaries_remain_bounded_and_action_oriented - # covers: architecture.memory_graph_surface_rollout_and_governance_actions.canonical_routes_remain_product_and_governed - # covers: package.jido_code.version_controlled_quality_surfaces - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Governance.{Decision, Evidence} - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope} - alias JidoCode.Orchestration.{Run, WorkflowRun} - alias JidoCode.Projects.Project - - setup do - original_memory = Application.get_env(:jido_code, :memory_graph_enabled, false) - - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, original_memory) - end) - - :ok - end - - test "34.4.1.1 dashboard summaries and governed run surfaces stay bounded and canonical", %{ - conn: _conn - } do - register_owner("phase34-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase34-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "phase-34-live-repo", - github_full_name: "owner/phase-34-live-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - seed_repo_memory_graph!(managed_repo_id, workspace_path, "rev-34-live-dashboard") - {run, evidence, decision} = seed_governed_run!(project, managed_repo_id, workspace_path) - - {:ok, dashboard_view, _html} = live(recycle(authed_conn), ~p"/dashboard", on_error: :warn) - - assert has_element?(dashboard_view, "#dashboard-memory-summary-list") - assert render(dashboard_view) =~ "owner/phase-34-live-repo" - assert has_element?(dashboard_view, ~s([id^="dashboard-memory-summary-link-"])) - - {:ok, run_view, _run_html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run.run_id}", on_error: :warn) - - assert has_element?(run_view, "#run-detail-governed-memory-contexts") - assert has_element?(run_view, "#run-detail-memory-follow-up-preview") - assert has_element?(run_view, "#run-detail-evidence-memory-#{evidence.id}-memory-list") - assert has_element?(run_view, "#run-detail-decision-memory-supersede-#{decision.id}") - - render_click(element(run_view, "#run-detail-evidence-memory-#{evidence.id}-memory-promote-1")) - assert has_element?(run_view, "#run-detail-memory-action-feedback", "Created governed follow-up work item") - end - - defp create_workspace_path! do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_phase_thirty_four_live_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule PhaseThirtyFourLive.MixProject do - use Mix.Project - - def project do - [app: :phase_thirty_four_live, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - File.write!( - Path.join(workspace_path, "lib/example_phase_thirty_four_live.ex"), - """ - defmodule ExamplePhaseThirtyFourLive do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp seed_repo_memory_graph!(managed_repo_id, workspace_path, revision) do - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "phase-34-live-#{System.unique_integer([:positive])}" - - assert {:ok, _provenance_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.review( - session_id: session_id, - actor_id: "system:phase-thirty-four-live", - workflow: :review, - work_item_id: "work-34", - content: "Generated a review artifact for phase thirty-four live tests.", - anchors: %{module_name: "ExamplePhaseThirtyFourLive"} - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.known_issue( - id: "known-issue-live", - session_id: session_id, - actor_id: "system:phase-thirty-four-live", - workflow: :review, - work_item_id: "work-34", - content: "Greeting contract changes require bounded follow-up review.", - revision: revision, - anchors: %{module_name: "ExamplePhaseThirtyFourLive"}, - governed_context: %{run_id: "run-34", work_item_id: "work-34"}, - classification: %{ - source: "phase_thirty_four_live", - reason: "Phase 34 live integration needs durable repository memory." - } - ), - revision: revision - ) - end - - defp seed_governed_run!(project, managed_repo_id, workspace_path) do - run_id = "phase-34-live-run-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Seed phase thirty-four live memory context"}, - input_metadata: %{"task_summary" => %{required: true, source: "phase_thirty_four_live"}}, - initiating_actor: %{id: "owner-34", email: "phase34-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-11 04:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-11 04:00:30Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-11 04:01:00Z] - }) - - {:ok, run} = - Run.get_by_managed_repo_and_run_id( - managed_repo_id, - run_id, - actor: Actor.operator_actor() - ) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo_id, - key: "phase_thirty_four_live_history", - evidence_type: "memory_graph_finding", - summary: "Phase 34 live integration keeps run memory context visible.", - evidence_details: %{"source" => "phase_thirty_four_live"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "phase-thirty-four-live-#{run.id}", - run_id: run.id, - managed_repo_id: managed_repo_id, - decision: :approve, - actor: %{"id" => "owner-34", "email" => "phase34-owner@example.com"}, - rationale: "Phase 34 live integration keeps canonical memory actions visible.", - decision_metadata: %{"source" => "phase_thirty_four_live"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - assert {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - - seed_run_memory_context!( - managed_repo_id, - workspace_path, - revision_metadata.current_revision, - run.run_id, - evidence.id, - decision.id - ) - - {run, evidence, decision} - end - - defp seed_run_memory_context!(managed_repo_id, workspace_path, revision, run_id, evidence_id, decision_id) do - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "phase-34-live-run-memory-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:phase-thirty-four-live-run", - workflow: :review, - work_item_id: "work-34", - goal: "Seed phase thirty-four live run memory context" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.decision( - session_id: session_id, - actor_id: "system:phase-thirty-four-live-run", - workflow: :review, - work_item_id: "work-34", - content: "Run detail should stage bounded follow-up from decision memory.", - rationale: "The canonical run surface must explain memory-driven follow-up.", - decision_status: :accepted, - revision: revision, - anchors: %{module_name: "ExamplePhaseThirtyFourLive"}, - governed_context: %{run_id: run_id, evidence_id: evidence_id, decision_id: decision_id}, - classification: %{ - source: "phase_thirty_four_live", - reason: "Phase 34 live integration needs decision memory on the governed surface." - } - ), - revision: revision - ) - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end -end diff --git a/test/jido_code_web/live/phase_thirty_two_integration_test.exs b/test/jido_code_web/live/phase_thirty_two_integration_test.exs deleted file mode 100644 index c1a759d5..00000000 --- a/test/jido_code_web/live/phase_thirty_two_integration_test.exs +++ /dev/null @@ -1,197 +0,0 @@ -defmodule JidoCodeWeb.PhaseThirtyTwoIntegrationTest do - # covers: architecture.memory_graph_product_adoption.managed_repo_routes_host_memory_and_provenance_inspection - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.memory_graph_product_adoption.operator_surfaces_do_not_expose_raw_memory_graph_internals - # covers: architecture.frontend_stack.semantic_operator_surfaces_can_use_bounded_hybrid_regions - # covers: package.jido_code.version_controlled_quality_surfaces - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope} - alias JidoCode.Projects.Project - - setup do - original_memory_graph_enabled = - Application.get_env(:jido_code, :memory_graph_enabled, false) - - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, original_memory_graph_enabled) - end) - - :ok - end - - test "32.4.1.2 repo detail memory surfaces stay legible under ready and invalidated states", %{ - conn: _conn - } do - register_owner("phase32-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase32-owner@example.com", "owner-password-123") - - ready_workspace = create_workspace_path!("ExamplePhaseThirtyTwo.Ready") - invalidated_workspace = create_workspace_path!("ExamplePhaseThirtyTwo.Invalidated") - - {:ok, ready_project} = - Project.create(%{ - name: "phase-32-ready-repo", - github_full_name: "owner/phase-32-ready-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => ready_workspace, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, invalidated_project} = - Project.create(%{ - name: "phase-32-invalidated-repo", - github_full_name: "owner/phase-32-invalidated-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => invalidated_workspace, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - ready_repo_id = managed_repo_route_id!(ready_project.id) - invalidated_repo_id = managed_repo_route_id!(invalidated_project.id) - - seed_memory_graph!(ready_repo_id, ready_workspace, "rev-32-ready-ui") - seed_memory_graph!(invalidated_repo_id, invalidated_workspace, "rev-32-invalidated-ui") - - assert {:ok, _invalidate_result} = - AgentWorkspace.invalidate_memory_graph( - invalidated_repo_id, - invalidated_workspace, - reason: :manual_invalidation - ) - - {:ok, ready_view, _ready_html} = - live(recycle(authed_conn), ~p"/repos/#{ready_project.id}", on_error: :warn) - - assert has_element?(ready_view, "#project-detail-memory-inspection") - assert has_element?(ready_view, "#project-detail-memory-summary-memories") - assert has_element?(ready_view, "#project-detail-memory-summary-provenance") - assert has_element?(ready_view, "#project-detail-memory-list") - - {:ok, invalidated_view, _invalidated_html} = - live(recycle(authed_conn), ~p"/repos/#{invalidated_project.id}", on_error: :warn) - - assert has_element?(invalidated_view, "#project-detail-memory-notice") - assert has_element?(invalidated_view, "#project-detail-memory-recover", "Validate memory graph") - - invalidated_view - |> element("#project-detail-memory-recover") - |> render_click() - - assert has_element?(invalidated_view, "#project-detail-memory-feedback-type", "memory_graph_recovered") - end - - defp create_workspace_path!(module_name) do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_phase_thirty_two_live_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule PhaseThirtyTwoLive.MixProject do - use Mix.Project - - def project do - [app: :phase_thirty_two_live, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - File.write!( - Path.join(workspace_path, "lib/example_phase_thirty_two_live.ex"), - """ - defmodule #{module_name} do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp seed_memory_graph!(managed_repo_id, workspace_path, revision) do - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "phase-32-live-#{System.unique_integer([:positive])}" - - assert {:ok, _provenance_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.review( - session_id: session_id, - actor_id: "system:phase-thirty-two-live", - workflow: :review, - work_item_id: "work-32", - content: "Generated a review artifact for phase thirty-two live tests.", - anchors: %{module_name: "ExamplePhaseThirtyTwoLive"} - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.known_issue( - id: "known-issue-run", - session_id: session_id, - actor_id: "system:phase-thirty-two-live", - workflow: :review, - work_item_id: "work-32", - content: "Greeting contract changes require governed review.", - revision: revision, - anchors: %{module_name: "ExamplePhaseThirtyTwoLive"}, - governed_context: %{run_id: "run-32", work_item_id: "work-32"}, - classification: %{ - source: "phase_thirty_two_live", - reason: "Phase 32 live integration needs durable repository memory." - } - ), - revision: revision - ) - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end -end diff --git a/test/jido_code_web/live/phase_twelve_integration_test.exs b/test/jido_code_web/live/phase_twelve_integration_test.exs deleted file mode 100644 index c99e4845..00000000 --- a/test/jido_code_web/live/phase_twelve_integration_test.exs +++ /dev/null @@ -1,34 +0,0 @@ -defmodule JidoCodeWeb.PhaseTwelveIntegrationTest do - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.live_vue_is_canonical_rich_component_bridge - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.testing_keeps_liveview_and_adds_live_vue_aware_helpers - use JidoCodeWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - test "shared host shell can mount a live vue component", %{conn: conn} do - {:ok, view, _html} = live_isolated(conn, JidoCodeWeb.TestSupport.LiveVueHostShellLive) - - vue = LiveVue.Test.get_vue(view, id: "shell-probe") - - assert vue.component == "ShellProbe" - assert vue.props["label"] == "LiveVue ready" - assert vue.ssr == false - assert vue.use_diff == false - end - - test "plain liveview routes render without a vue mount", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/welcome") - - refute html =~ ~s(phx-hook="VueHook") - end - - test "root layout renders test-safe vite asset references", %{conn: conn} do - conn = get(conn, ~p"/welcome") - html = html_response(conn, 200) - - assert html =~ "assets/test.css?vsn=d" - assert html =~ "assets/test.js?vsn=d" - end -end diff --git a/test/jido_code_web/live/phase_twenty_five_integration_test.exs b/test/jido_code_web/live/phase_twenty_five_integration_test.exs deleted file mode 100644 index aefbfa3f..00000000 --- a/test/jido_code_web/live/phase_twenty_five_integration_test.exs +++ /dev/null @@ -1,240 +0,0 @@ -defmodule JidoCodeWeb.PhaseTwentyFiveIntegrationTest do - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.factory_control_plane.semantic_repository_insights_rejoin_control_plane - # covers: architecture.source_code_graph_product_adoption.managed_repo_routes_host_semantic_inspection - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.source_code_graph_product_adoption.operator_surfaces_do_not_expose_raw_graph_internals - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.semantic_operator_surfaces_can_use_bounded_hybrid_regions - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - # covers: setup.onboarding.post_bootstrap_surfaces_adopt_control_plane_language - # covers: package.jido_code.version_controlled_quality_surfaces - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Projects.Project - - setup do - original_source_code_graph_enabled = - Application.get_env(:jido_code, :source_code_graph_enabled, false) - - original_frontend_override = - Application.get_env(:jido_code, :frontend_assets_override, :__missing__) - - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :source_code_graph_enabled, original_source_code_graph_enabled) - restore_env(:frontend_assets_override, original_frontend_override) - end) - - :ok - end - - test "25.3.1 managed-repository semantic inspection stays repo-scoped across repo detail and workbench", %{ - conn: _conn - } do - register_owner("phase25-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase25-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("PhaseTwentyFive.Semantic") - - {:ok, project} = - Project.create(%{ - name: "phase-25-semantic-repo", - github_full_name: "owner/phase-25-semantic-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - assert {:ok, _load_result} = AgentWorkspace.load_source_code_graph(managed_repo_id, workspace_path) - - rewrite_semantic_workspace_module!(workspace_path, "PhaseTwentyFive.SemanticRefreshed") - - {:ok, repo_view, repo_html} = - live(recycle(authed_conn), ~p"/repos/#{managed_repo_id}", on_error: :warn) - - assert has_element?(repo_view, "#project-detail-semantic-notice") - assert has_element?(repo_view, "#project-detail-semantic-notice-type", "source_code_graph_stale") - assert has_element?(repo_view, "#project-detail-semantic-recover", "Refresh semantic graph") - assert has_element?(repo_view, "#project-detail-managed-repo-id", managed_repo_id) - refute repo_html =~ "SELECT ?" - refute repo_html =~ "TripleStore" - refute repo_html =~ "pod_id" - - semantic_vue = - assert_vue_component( - repo_view, - "ProjectDetailSemanticExplorerWidget", - id: "project-detail-semantic-explorer-widget" - ) - - assert semantic_vue.props["managedRepoId"] == managed_repo_id - assert semantic_vue.props["graph"]["state"] == "stale" - - {:ok, workbench_view, _workbench_html} = - live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - workbench_view, - "#workbench-project-semantic-hint-badge-#{managed_repo_id}", - "Semantic graph stale" - ) - - assert has_element?( - workbench_view, - "#workbench-project-semantic-hint-recovery-#{managed_repo_id}[href='/repos/#{managed_repo_id}']", - "Open repo detail to refresh semantic graph data." - ) - end - - test "25.3.2 hybrid semantic explorer regions fall back safely when richer delivery degrades", %{ - conn: _conn - } do - register_owner("phase25-fallback-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase25-fallback-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - workspace_path = create_semantic_workspace_path!("PhaseTwentyFive.Fallback") - - {:ok, project} = - Project.create(%{ - name: "phase-25-fallback-repo", - github_full_name: "owner/phase-25-fallback-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - assert {:ok, _load_result} = AgentWorkspace.load_source_code_graph(managed_repo_id, workspace_path) - - {:ok, view, html} = live(recycle(authed_conn), ~p"/repos/#{managed_repo_id}", on_error: :warn) - - assert has_element?( - view, - "#project-detail-semantic-explorer-widget-fallback", - "Interactive semantic explorer temporarily unavailable" - ) - - assert has_element?( - view, - "#project-detail-semantic-explorer-widget-fallback", - "server-rendered semantic summary" - ) - - assert has_element?(view, "#project-detail-semantic-fallback") - assert has_element?(view, "#project-detail-semantic-fallback-modules") - refute html =~ "SELECT ?module" - refute html =~ "TripleStore" - end - - @tag skip: "repo-local .spec workspace was removed" - test "25.3.2 docs and specs remain aligned with semantic operator adoption" do - phase_plan = repo_file!(".planning/phase-25-semantic-operator-surface-adoption.md") - source_graph_spec = repo_file!(".spec/specs/source_code_graph_product_adoption.spec.md") - frontend_spec = repo_file!(".spec/specs/frontend_architecture.spec.md") - - assert phase_plan =~ "[x] 25 Phase 25 - Semantic Operator Surface Adoption" - assert phase_plan =~ "[x] 25.3 Section - Phase 25 Integration Tests" - assert source_graph_spec =~ "lib/jido_code/workbench/project_semantic_inspection.ex" - assert source_graph_spec =~ "test/jido_code_web/live/phase_twenty_five_integration_test.exs" - assert frontend_spec =~ "architecture.frontend_stack.semantic_operator_surfaces_can_use_bounded_hybrid_regions" - assert frontend_spec =~ "test/jido_code_web/live/phase_twenty_five_integration_test.exs" - end - - defp create_semantic_workspace_path!(module_name) do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_phase_twenty_five_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule PhaseTwentyFive.MixProject do - use Mix.Project - - def project do - [app: :phase_twenty_five, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - rewrite_semantic_workspace_module!(workspace_path, module_name) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp rewrite_semantic_workspace_module!(workspace_path, module_name) do - module_basename = - module_name - |> String.split(".") - |> List.last() - |> Macro.underscore() - - File.write!( - Path.join(workspace_path, "lib/#{module_basename}.ex"), - """ - defmodule #{module_name} do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - workspace_path - |> Path.join("lib/*.ex") - |> Path.wildcard() - |> Enum.reject(&String.ends_with?(&1, "#{module_basename}.ex")) - |> Enum.each(&File.rm!/1) - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end - - defp repo_file!(path) do - Path.expand(path, repo_root()) |> File.read!() - end - - defp repo_root do - Path.expand("../../..", __DIR__) - end -end diff --git a/test/jido_code_web/live/phase_twenty_seven_integration_test.exs b/test/jido_code_web/live/phase_twenty_seven_integration_test.exs deleted file mode 100644 index 9d78d54e..00000000 --- a/test/jido_code_web/live/phase_twenty_seven_integration_test.exs +++ /dev/null @@ -1,177 +0,0 @@ -defmodule JidoCodeWeb.PhaseTwentySevenIntegrationTest do - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.source_code_graph_product_adoption.operator_surfaces_do_not_expose_raw_graph_internals - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.frontend_stack.semantic_operator_surfaces_can_use_bounded_hybrid_regions - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - # covers: package.jido_code.version_controlled_quality_surfaces - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.Projects.Project - - setup do - original_source_code_graph_enabled = - Application.get_env(:jido_code, :source_code_graph_enabled, false) - - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :source_code_graph_enabled, original_source_code_graph_enabled) - end) - - :ok - end - - test "27.3.1.1 semantic operator surfaces stay legible under mixed stale and ready repository states", %{ - conn: _conn - } do - register_owner("phase27-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("phase27-owner@example.com", "owner-password-123") - - stale_workspace = create_semantic_workspace_path!("PhaseTwentySeven.Stale") - ready_workspace = create_semantic_workspace_path!("PhaseTwentySeven.Ready") - - {:ok, stale_project} = - Project.create(%{ - name: "phase-27-stale-repo", - github_full_name: "owner/phase-27-stale-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => stale_workspace, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, ready_project} = - Project.create(%{ - name: "phase-27-ready-repo", - github_full_name: "owner/phase-27-ready-repo", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => ready_workspace, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - stale_repo_id = managed_repo_route_id!(stale_project.id) - ready_repo_id = managed_repo_route_id!(ready_project.id) - - assert {:ok, _load_stale} = - AgentWorkspace.load_source_code_graph(stale_repo_id, stale_workspace) - - assert {:ok, _load_ready} = - AgentWorkspace.load_source_code_graph(ready_repo_id, ready_workspace) - - rewrite_semantic_workspace_module!(stale_workspace, "PhaseTwentySeven.StaleRenamed") - - {:ok, stale_view, _stale_html} = - live(recycle(authed_conn), ~p"/repos/#{stale_repo_id}", on_error: :warn) - - assert has_element?(stale_view, "#project-detail-semantic-notice") - assert has_element?(stale_view, "#project-detail-semantic-notice-type", "source_code_graph_stale") - assert has_element?(stale_view, "#project-detail-semantic-recover", "Refresh semantic graph") - - {:ok, ready_view, _ready_html} = - live(recycle(authed_conn), ~p"/repos/#{ready_repo_id}", on_error: :warn) - - refute has_element?(ready_view, "#project-detail-semantic-notice") - - ready_semantic_vue = - assert_vue_component( - ready_view, - "ProjectDetailSemanticExplorerWidget", - id: "project-detail-semantic-explorer-widget" - ) - - assert ready_semantic_vue.props["graph"]["state"] == "ready" - - {:ok, workbench_view, _workbench_html} = - live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - workbench_view, - "#workbench-project-semantic-hint-badge-#{stale_repo_id}", - "Semantic graph stale" - ) - - assert has_element?( - workbench_view, - "#workbench-project-semantic-hint-badge-#{ready_repo_id}", - "Semantic graph ready" - ) - end - - defp create_semantic_workspace_path!(module_name) do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_phase_twenty_seven_live_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule PhaseTwentySeven.MixProject do - use Mix.Project - - def project do - [app: :phase_twenty_seven, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - rewrite_semantic_workspace_module!(workspace_path, module_name) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp rewrite_semantic_workspace_module!(workspace_path, module_name) do - module_basename = - module_name - |> String.split(".") - |> List.last() - |> Macro.underscore() - - File.write!( - Path.join(workspace_path, "lib/#{module_basename}.ex"), - """ - defmodule #{module_name} do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - workspace_path - |> Path.join("lib/*.ex") - |> Path.wildcard() - |> Enum.reject(&String.ends_with?(&1, "#{module_basename}.ex")) - |> Enum.each(&File.rm!/1) - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end -end diff --git a/test/jido_code_web/live/project_detail_live_test.exs b/test/jido_code_web/live/project_detail_live_test.exs deleted file mode 100644 index 45720e36..00000000 --- a/test/jido_code_web/live/project_detail_live_test.exs +++ /dev/null @@ -1,1632 +0,0 @@ -defmodule JidoCodeWeb.ProjectDetailLiveTest do - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.conversation_routes_keep_runtime_and_recovery_liveview_owned - # covers: architecture.source_code_graph_product_adoption.managed_repo_routes_host_semantic_inspection - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.memory_graph_product_adoption.managed_repo_routes_host_memory_and_provenance_inspection - # covers: architecture.memory_graph_product_adoption.memory_and_provenance_views_can_cross_link_to_source_code - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.conversation_orchestration.ui_delivery_is_event_driven_and_reconnectable - # covers: architecture.conversation_orchestration.degraded_mode_falls_back_to_persisted_state - # covers: architecture.conversation_orchestration.managed_repo_routes_host_repo_conversations - # covers: architecture.conversation_orchestration.operator_surfaces_show_conversation_work_item_linkage - # covers: architecture.conversation_orchestration.real_llm_turn_execution_replaces_surface_simulation - # covers: architecture.conversation_orchestration.llm_readiness_and_failure_states_are_explicit - # covers: architecture.conversation_orchestration.route_level_runtime_readiness_and_continuity_are_operator_readable - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Accounts.User - alias JidoCode.Control.{Actor, ManagedRepo} - alias JidoCode.ManagedRepoRoutes - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope} - alias JidoCode.Projects.Project - - setup do - original_fix_workflow_launcher = - Application.get_env(:jido_code, :workbench_fix_workflow_launcher, :__missing__) - - original_issue_triage_workflow_launcher = - Application.get_env(:jido_code, :workbench_issue_triage_workflow_launcher, :__missing__) - - original_frontend_override = - Application.get_env(:jido_code, :frontend_assets_override, :__missing__) - - original_source_code_graph_enabled = - Application.get_env(:jido_code, :source_code_graph_enabled, false) - - original_memory_graph_enabled = - Application.get_env(:jido_code, :memory_graph_enabled, false) - - original_conversation_pubsub_subscriber = - Application.get_env(:jido_code, :conversation_pubsub_subscriber, :__missing__) - - on_exit(fn -> - restore_env(:workbench_fix_workflow_launcher, original_fix_workflow_launcher) - - restore_env( - :workbench_issue_triage_workflow_launcher, - original_issue_triage_workflow_launcher - ) - - restore_env(:frontend_assets_override, original_frontend_override) - restore_env(:conversation_pubsub_subscriber, original_conversation_pubsub_subscriber) - Application.put_env(:jido_code, :source_code_graph_enabled, original_source_code_graph_enabled) - Application.put_env(:jido_code, :memory_graph_enabled, original_memory_graph_enabled) - end) - - :ok - end - - test "launches supported builtin workflows from /repos/:id with defaults and repo-detail traceability", - %{conn: _conn} do - bootstrap_owner!("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-ready", - github_full_name: "owner/repo-ready", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - project_id = project.id - managed_repo_id = managed_repo_route_id!(project_id) - launch_requests = start_supervised!({Agent, fn -> [] end}) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn kickoff_request -> - Agent.update(launch_requests, fn requests -> [kickoff_request | requests] end) - {:ok, %{run_id: "run-fix-123"}} - end) - - Application.put_env( - :jido_code, - :workbench_issue_triage_workflow_launcher, - fn kickoff_request -> - Agent.update(launch_requests, fn requests -> [kickoff_request | requests] end) - {:ok, %{run_id: "run-triage-456"}} - end - ) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project_id}?subject=work§ion=workflows", - on_error: :warn - ) - - project_return_to = - ManagedRepoRoutes.project_detail_path( - managed_repo_id, - return_to: "/repos", - subject: :work, - section: :workflows - ) - - encoded_project_return_to = URI.encode_www_form(project_return_to) - - assert has_element?(view, "[data-detail-subject='work'][data-detail-section='workflows']") - assert has_element?(view, "#project-detail-shell-parent-subjects-work[aria-current='page']") - assert has_element?(view, "#project-detail-workflow-readiness-summary") - assert has_element?(view, "#project-detail-workflow-readiness-badge", "Ready") - assert has_element?(view, "#project-detail-workflow-controls") - - assert has_element?( - view, - "#project-detail-workflow-name-fix-failing-tests", - "fix_failing_tests" - ) - - assert has_element?(view, "#project-detail-workflow-name-issue-triage", "issue_triage") - assert has_element?(view, "#project-detail-launch-fix-failing-tests") - assert has_element?(view, "#project-detail-launch-issue-triage") - refute has_element?(view, "#project-detail-launch-disabled-guidance") - - view - |> element("#project-detail-launch-fix-failing-tests") - |> render_click() - - assert has_element?(view, "#project-detail-launch-fix-failing-tests-run-id", "run-fix-123") - - assert has_element?( - view, - "#project-detail-launch-fix-failing-tests-run-link[href='/repos/#{managed_repo_id}/runs/run-fix-123?return_to=#{encoded_project_return_to}']" - ) - - view - |> element("#project-detail-launch-issue-triage") - |> render_click() - - assert has_element?(view, "#project-detail-launch-issue-triage-run-id", "run-triage-456") - - assert has_element?( - view, - "#project-detail-launch-issue-triage-run-link[href='/repos/#{managed_repo_id}/runs/run-triage-456?return_to=#{encoded_project_return_to}']" - ) - - recorded_requests = launch_requests |> Agent.get(&Enum.reverse(&1)) - project_route = project_return_to - - assert [ - %{ - workflow_name: "fix_failing_tests", - project_id: ^managed_repo_id, - return_to: ^project_return_to, - project_defaults: %{ - default_branch: "main", - github_full_name: "owner/repo-ready" - }, - trigger: %{ - source: "project_detail", - mode: "manual", - source_row: %{ - route: ^project_route, - project_id: ^managed_repo_id - } - }, - context_item: %{type: :issue}, - initiating_actor: %{id: fix_actor_id} - }, - %{ - workflow_name: "issue_triage", - project_id: ^managed_repo_id, - return_to: ^project_return_to, - project_defaults: %{ - default_branch: "main", - github_full_name: "owner/repo-ready" - }, - trigger: %{ - source: "project_detail", - mode: "manual", - source_row: %{ - route: ^project_route, - project_id: ^managed_repo_id - } - }, - context_item: %{type: :issue}, - initiating_actor: %{id: triage_actor_id} - } - ] = recorded_requests - - assert is_binary(fix_actor_id) - assert fix_actor_id != "" - assert is_binary(triage_actor_id) - assert triage_actor_id != "" - - assert Enum.all?(recorded_requests, fn kickoff_request -> - Map.has_key?(kickoff_request.initiating_actor, :email) - end) - end - - test "repo detail keeps route-owned subject selection with overview as the default readiness subject", %{ - conn: _conn - } do - bootstrap_owner!("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-sections", - github_full_name: "owner/repo-sections", - default_branch: "main", - settings: %{ - "workspace" => %{ - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, default_view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - assert has_element?( - default_view, - "section[id^='project-detail-panel-'][data-detail-subject='readiness'][data-detail-section='overview']" - ) - - assert has_element?(default_view, "#project-detail-shell-parent-subjects-readiness[aria-current='page']") - assert has_element?(default_view, "#project-detail-section-nav-overview[aria-current='page']") - assert has_element?(default_view, "#project-detail-overview-panel") - assert has_element?(default_view, "#project-detail-overview-open-workflows") - refute has_element?(default_view, "#project-detail-conversation-panel") - refute has_element?(default_view, "#project-detail-workflow-controls") - - {:ok, memory_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}?subject=knowledge§ion=memory", - on_error: :warn - ) - - assert has_element?( - memory_view, - "section[id^='project-detail-panel-'][data-detail-subject='knowledge'][data-detail-section='memory']" - ) - - assert has_element?(memory_view, "#project-detail-shell-parent-subjects-knowledge[aria-current='page']") - assert has_element?(memory_view, "#project-detail-section-nav-memory[aria-current='page']") - assert has_element?(memory_view, "#project-detail-memory-inspection") - refute has_element?(memory_view, "#project-detail-workflow-controls") - - {:ok, conversations_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}?subject=work§ion=conversations", - on_error: :warn - ) - - assert has_element?( - conversations_view, - "section[id^='project-detail-panel-'][data-detail-subject='work'][data-detail-section='conversations']" - ) - - assert has_element?(conversations_view, "#project-detail-shell-parent-subjects-work[aria-current='page']") - - assert has_element?( - conversations_view, - "#project-detail-section-nav-conversations[aria-current='page']" - ) - - assert has_element?(conversations_view, "#project-detail-conversation-panel") - refute has_element?(conversations_view, "#project-detail-memory-inspection") - - {:ok, invalid_view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}?subject=unknown§ion=unknown", - on_error: :warn - ) - - assert has_element?( - invalid_view, - "section[id^='project-detail-panel-'][data-detail-subject='readiness'][data-detail-section='overview']" - ) - - {:ok, fallback_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=memory", on_error: :warn) - - assert has_element?( - fallback_view, - "section[id^='project-detail-panel-'][data-detail-subject='knowledge'][data-detail-section='memory']" - ) - end - - test "route-owned parent and child navigation switches repo-detail families in place", %{ - conn: _conn - } do - bootstrap_owner!("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-section-navigation", - github_full_name: "owner/repo-section-navigation", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - expected_work_conversations_path = - ManagedRepoRoutes.project_detail_path( - managed_repo_id, - return_to: "/repos", - subject: :work, - section: :conversations - ) - - expected_work_workflows_path = - ManagedRepoRoutes.project_detail_path( - managed_repo_id, - return_to: "/repos", - subject: :work, - section: :workflows - ) - - expected_knowledge_semantic_path = - ManagedRepoRoutes.project_detail_path( - managed_repo_id, - return_to: "/repos", - subject: :knowledge, - section: :semantic - ) - - expected_knowledge_memory_path = - ManagedRepoRoutes.project_detail_path( - managed_repo_id, - return_to: "/repos", - subject: :knowledge, - section: :memory - ) - - view - |> element("#project-detail-shell-parent-subjects-work") - |> render_click() - - assert_patch(view, expected_work_conversations_path) - assert has_element?(view, "[data-detail-subject='work'][data-detail-section='conversations']") - assert has_element?(view, "#project-detail-shell-parent-subjects-work[aria-current='page']") - assert has_element?(view, "#project-detail-section-nav-conversations[aria-current='page']") - assert has_element?(view, "#project-detail-conversation-panel") - - view - |> element("#project-detail-section-nav-workflows") - |> render_click() - - assert_patch(view, expected_work_workflows_path) - assert has_element?(view, "#project-detail-section-nav-workflows[aria-current='page']") - assert has_element?(view, "#project-detail-workflows-panel") - refute has_element?(view, "#project-detail-overview-panel") - - view - |> element("#project-detail-section-nav-conversations") - |> render_click() - - assert_patch(view, expected_work_conversations_path) - assert has_element?(view, "#project-detail-section-nav-conversations[aria-current='page']") - assert has_element?(view, "#project-detail-conversation-panel") - assert has_element?(view, "#project-detail-conversation-runtime-status", "Ready") - refute has_element?(view, "#project-detail-workflows-panel") - - view - |> element("#project-detail-shell-parent-subjects-knowledge") - |> render_click() - - assert_patch(view, expected_knowledge_semantic_path) - assert has_element?(view, "[data-detail-subject='knowledge'][data-detail-section='semantic']") - assert has_element?(view, "#project-detail-shell-parent-subjects-knowledge[aria-current='page']") - assert has_element?(view, "#project-detail-section-nav-semantic[aria-current='page']") - assert has_element?(view, "#project-detail-semantic-inspection") - refute has_element?(view, "#project-detail-conversation-panel") - - view - |> element("#project-detail-semantic-open-memory") - |> render_click() - - assert_patch(view, expected_knowledge_memory_path) - assert has_element?(view, "#project-detail-section-nav-memory[aria-current='page']") - assert has_element?(view, "#project-detail-memory-inspection") - refute has_element?(view, "#project-detail-semantic-inspection") - - view - |> element("#project-detail-memory-open-semantic") - |> render_click() - - assert_patch(view, expected_knowledge_semantic_path) - assert has_element?(view, "#project-detail-semantic-inspection") - refute has_element?(view, "#project-detail-memory-inspection") - end - - test "repo detail renders breadcrumbs before child subjects and targets the selected pane", %{ - conn: _conn - } do - bootstrap_owner!("repo-shell-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("repo-shell-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-shell-layout", - github_full_name: "owner/repo-shell-layout", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}?subject=knowledge§ion=memory", - on_error: :warn - ) - - html = render(view) - - assert rendered_fragment_index(html, ~s(id="project-detail-github-full-name")) < - rendered_fragment_index(html, ~s(id="project-detail-shell-breadcrumbs")) - - assert rendered_fragment_index(html, ~s(id="project-detail-shell-breadcrumbs")) < - rendered_fragment_index(html, ~s(id="project-detail-shell-parent-subjects")) - - assert rendered_fragment_index(html, ~s(id="project-detail-shell-parent-subjects")) < - rendered_fragment_index(html, ~s(id="project-detail-section-nav")) - - assert has_element?(view, "#project-detail-breadcrumb-subject", "Knowledge") - assert has_element?(view, "#project-detail-breadcrumb-current[aria-current='page']", "Memory") - assert has_element?(view, "#project-detail-breadcrumb-return[href='/repos']", "Repositories") - assert has_element?(view, "#project-detail-return-link[href='/repos']", "Back to Repositories") - assert has_element?(view, "#project-detail-shell-parent-subjects-knowledge[aria-current='page']") - - assert has_element?( - view, - "#project-detail-section-nav-memory[aria-controls='project-detail-pane-memory'][aria-current='page']" - ) - - assert has_element?(view, "#project-detail-pane-memory[role='region']") - assert has_element?(view, "#project-detail-pane-memory-header") - assert has_element?(view, "#project-detail-pane-memory-middle") - assert has_element?(view, "#project-detail-pane-memory-footer") - end - - test "disables project-detail launch controls with remediation when execution prerequisites are blocked", - %{ - conn: _conn - } do - bootstrap_owner!("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-blocked", - github_full_name: "owner/repo-blocked", - default_branch: "main", - settings: %{ - "workspace" => %{ - "clone_status" => "error", - "last_error_type" => "baseline_sync_unavailable", - "retry_instructions" => "Retry step 7 after baseline sync is repaired." - } - } - }) - - launcher_invocations = start_supervised!({Agent, fn -> %{fix: 0, triage: 0} end}) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn _kickoff_request -> - Agent.update(launcher_invocations, fn state -> Map.update!(state, :fix, &(&1 + 1)) end) - {:ok, %{run_id: "unexpected-fix-run"}} - end) - - Application.put_env( - :jido_code, - :workbench_issue_triage_workflow_launcher, - fn _kickoff_request -> - Agent.update(launcher_invocations, fn state -> Map.update!(state, :triage, &(&1 + 1)) end) - {:ok, %{run_id: "unexpected-triage-run"}} - end - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=workflows", on_error: :warn) - - assert has_element?(view, "#project-detail-workflow-readiness-summary") - assert has_element?(view, "#project-detail-workflow-readiness-badge", "Blocked") - assert has_element?(view, "#project-detail-launch-disabled-guidance") - - assert has_element?( - view, - "#project-detail-launch-disabled-fix-failing-tests[aria-disabled='true']", - "Launch workflow" - ) - - assert has_element?( - view, - "#project-detail-launch-disabled-issue-triage[aria-disabled='true']", - "Launch workflow" - ) - - assert has_element?(view, "#project-detail-launch-disabled-type", "baseline_sync_unavailable") - - assert has_element?( - view, - "#project-detail-launch-disabled-detail", - "clone or baseline sync failed" - ) - - assert has_element?(view, "#project-detail-launch-disabled-remediation", "Retry step 7") - - refute has_element?(view, "#project-detail-launch-fix-failing-tests") - refute has_element?(view, "#project-detail-launch-issue-triage") - - assert %{fix: 0, triage: 0} = Agent.get(launcher_invocations, & &1) - end - - test "surfaces repo-scoped workspace remediation when workflow launch is blocked by missing binding", - %{ - conn: _conn - } do - bootstrap_owner!("workflow-binding-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workflow-binding-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-workflow-binding-blocked", - github_full_name: "owner/repo-workflow-binding-blocked", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=workflows", on_error: :warn) - - assert has_element?(view, "#project-detail-workflow-readiness-badge", "Blocked") - assert has_element?(view, "#project-detail-launch-disabled-type", "managed_repo_workspace_binding_missing") - - assert has_element?( - view, - "#project-detail-launch-disabled-detail", - "configured for local runtime, but no repo-scoped local workspace path is saved for workflow launch" - ) - - assert has_element?( - view, - "#project-detail-launch-disabled-remediation", - "Open Overview, save a local workspace path for this repository, and retry workflow launch." - ) - - assert has_element?(view, "#project-detail-launch-disabled-repair", "Repair workspace binding") - end - - test "renders project overview inside the overview subject", %{conn: _conn} do - bootstrap_owner!("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-overview-ui", - github_full_name: "owner/repo-overview-ui", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - vue = assert_vue_component(view, "ProjectDetailOverviewWidget", id: "project-detail-overview-widget") - - assert vue.props["githubFullName"] == "owner/repo-overview-ui" - assert vue.props["launchReady"] == true - assert length(vue.props["workflowCards"]) == 2 - - assert has_element?(view, "#project-detail-overview-panel") - assert has_element?(view, "#project-detail-shell-parent-subjects-readiness[aria-current='page']") - assert has_element?(view, "#project-detail-section-nav-overview[aria-current='page']") - assert has_element?(view, "#project-detail-overview-subject-guides") - assert has_element?(view, "#project-detail-overview-open-conversations") - assert has_element?(view, "#project-detail-overview-open-workflows") - refute has_element?(view, "#project-detail-conversation-panel") - refute has_element?(view, "#project-detail-workflow-controls") - end - - test "hosts repo conversation interaction inside the managed repo detail route", %{conn: _conn} do - bootstrap_owner!("conversation-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("conversation-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-conversation-ui", - github_full_name: "owner/repo-conversation-ui", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - on_exit(fn -> - case AgentWorkspace.latest_repo_conversation(managed_repo_id, actor: Actor.operator_actor()) do - {:ok, %{id: conversation_id}} -> - case AgentWorkspace.stop_conversation(conversation_id) do - :ok -> :ok - {:error, _reason} -> :ok - end - - _other -> - :ok - end - end) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=conversations", on_error: :warn) - - assert has_element?(view, "#project-detail-conversation-workspace-summary") - assert has_element?(view, "#project-detail-conversation-governed-count") - - view - |> element("#project-detail-conversation-open") - |> render_click() - - assert has_element?(view, "#project-detail-conversation-id") - assert has_element?(view, "#project-detail-conversation-status", "active") - - conversation_id = latest_repo_conversation_id!(managed_repo_id) - - html = - view - |> form("#project-detail-conversation-form", %{ - "input" => "Inspect the repo detail conversation flow." - }) - |> render_submit() - - assert html =~ "Inspect the repo detail conversation flow." - assert has_element?(view, "#project-detail-conversation-events") - - _html = - view - |> form("#project-detail-conversation-form", %{ - "input" => "Clarify which file needs input." - }) - |> render_submit() - - assert_eventually(fn -> - has_element?(view, "#project-detail-conversation-pending-clarification") - end) - - _html = - view - |> form("#project-detail-conversation-form", %{ - "input" => "lib/jido_code_web/live/project_detail_live.ex" - }) - |> render_submit() - - assert_eventually(fn -> - rendered = render(view) - - rendered =~ "deterministic explainer response" and - rendered =~ "Queue review operator request work for the managed repository." and - rendered =~ "canonical WorkItem scope" and - rendered =~ "lib/jido_code_web/live/project_detail_live.ex" and - has_element?(view, "#project-detail-conversation-work-resolution", "created") and - has_element?(view, "#project-detail-conversation-governed-work") and - has_element?(view, "#project-detail-conversation-governed-work-status", "open") and - has_element?(view, "#project-detail-conversation-open-workbench", "Open in Workbench") and - not has_element?(view, "#project-detail-conversation-pending-clarification") - end) - - assert_conversation_settled!(conversation_id) - assert :ok = AgentWorkspace.stop_conversation(conversation_id) - end - - test "surfaces explicit conversation runtime readiness failures on the repo detail route", %{ - conn: _conn - } do - bootstrap_owner!("conversation-runtime-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("conversation-runtime-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-conversation-runtime-missing-workspace", - github_full_name: "owner/repo-conversation-runtime-missing-workspace", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - on_exit(fn -> - case AgentWorkspace.latest_repo_conversation(managed_repo_id, actor: Actor.operator_actor()) do - {:ok, %{id: conversation_id}} -> - case AgentWorkspace.stop_conversation(conversation_id) do - :ok -> :ok - {:error, _reason} -> :ok - end - - _other -> - :ok - end - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos/#{project.id}", on_error: :warn) - - assert has_element?(view, "#project-detail-overview-guide-conversations-badge", "Blocked") - - view - |> element("#project-detail-overview-open-conversations") - |> render_click() - - assert has_element?(view, "#project-detail-conversation-runtime-status", "Blocked") - assert has_element?(view, "#project-detail-conversation-runtime-notice") - assert has_element?(view, "#project-detail-conversation-runtime-notice #project-detail-conversation-runtime-repair") - - assert has_element?( - view, - "#project-detail-conversation-runtime-notice-type", - "conversation_runtime_workspace_binding_missing" - ) - - assert has_element?( - view, - "#project-detail-conversation-runtime-workspace", - "No repo-scoped local workspace path saved" - ) - - view - |> element("#project-detail-conversation-runtime-repair") - |> render_click() - - assert has_element?(view, "#project-detail-overview-panel") - assert has_element?(view, "#project-detail-workspace-binding-panel") - - view - |> element("#project-detail-overview-open-conversations") - |> render_click() - - view - |> element("#project-detail-conversation-open") - |> render_click() - - conversation_id = latest_repo_conversation_id!(managed_repo_id) - - _html = - view - |> form("#project-detail-conversation-form", %{ - "input" => "Explain why this repository is ready." - }) - |> render_submit() - - assert_eventually(fn -> - rendered = render(view) - - rendered =~ - "configured for local runtime, but no repo-scoped local workspace path is saved for conversation runtime" and - has_element?(view, "#project-detail-conversation-runtime-preserved") and - not String.contains?(rendered, "deterministic explainer response") - end) - - assert_conversation_settled!(conversation_id) - assert :ok = AgentWorkspace.stop_conversation(conversation_id) - end - - test "repo detail can repair a blocked repo-scoped workspace binding directly from the route", %{ - conn: _conn - } do - bootstrap_owner!("conversation-runtime-repair-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn( - "conversation-runtime-repair-owner@example.com", - "owner-password-123" - ) - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-conversation-runtime-repair", - github_full_name: "owner/repo-conversation-runtime-repair", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=conversations", on_error: :warn) - - assert has_element?(view, "#project-detail-conversation-runtime-status", "Blocked") - assert has_element?(view, "#project-detail-conversation-runtime-notice #project-detail-conversation-runtime-repair") - - view - |> element("#project-detail-conversation-runtime-repair") - |> render_click() - - assert has_element?(view, "#project-detail-overview-panel") - assert has_element?(view, "#project-detail-workspace-binding-badge", "Needs path") - - assert has_element?( - view, - "#project-detail-workspace-binding-form-note", - "It does not rewrite setup defaults or sibling repositories." - ) - - view - |> form("#project-detail-workspace-binding-form", %{ - "workspace_binding" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path - } - }) - |> render_submit() - - assert has_element?(view, "#project-detail-workspace-binding-path", workspace_path) - assert has_element?(view, "#project-detail-workspace-binding-root", Path.dirname(workspace_path)) - assert has_element?(view, "#project-detail-workspace-binding-feedback-type") - - view - |> element("#project-detail-overview-open-conversations") - |> render_click() - - assert has_element?(view, "#project-detail-conversation-runtime-status", "Ready") - assert has_element?(view, "#project-detail-conversation-runtime-workspace", workspace_path) - refute has_element?(view, "#project-detail-conversation-runtime-notice") - end - - test "surfaces repo-scoped semantic and memory binding remediation from managed repo detail", %{ - conn: _conn - } do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - bootstrap_owner!("repo-workspace-surface-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("repo-workspace-surface-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-workspace-surface-blocked", - github_full_name: "owner/repo-workspace-surface-blocked", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, semantic_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=semantic", on_error: :warn) - - assert has_element?(semantic_view, "#project-detail-semantic-notice-type", "semantic_workspace_binding_unavailable") - - assert has_element?( - semantic_view, - "#project-detail-semantic-notice-detail", - "no repo-scoped local workspace path is saved for semantic inspection" - ) - - assert has_element?(semantic_view, "#project-detail-semantic-repair-workspace", "Repair workspace binding") - - {:ok, memory_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=memory", on_error: :warn) - - assert has_element?(memory_view, "#project-detail-memory-notice-type", "memory_workspace_binding_unavailable") - - assert has_element?( - memory_view, - "#project-detail-memory-notice-detail", - "no repo-scoped local workspace path is saved for memory inspection" - ) - - assert has_element?(memory_view, "#project-detail-memory-repair-workspace", "Repair workspace binding") - end - - test "shows bounded conversation runtime readiness with the selected LLM on repo detail", %{ - conn: _conn - } do - bootstrap_owner!("conversation-readiness-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("conversation-readiness-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-conversation-runtime-ready", - github_full_name: "owner/repo-conversation-runtime-ready", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=conversations", on_error: :warn) - - assert has_element?(view, "#project-detail-conversation-runtime") - assert has_element?(view, "#project-detail-conversation-runtime-status", "Ready") - assert has_element?(view, "#project-detail-conversation-runtime-llm", "openai:gpt-5-mini") - assert has_element?(view, "#project-detail-conversation-runtime-source", "Managed repo default") - assert has_element?(view, "#project-detail-conversation-runtime-workspace", workspace_path) - refute has_element?(view, "#project-detail-conversation-runtime-notice") - end - - test "keeps degraded conversation continuity legible when live subscription falls back to snapshots", %{ - conn: _conn - } do - Application.put_env( - :jido_code, - :conversation_pubsub_subscriber, - JidoCodeWeb.FailingConversationSubscriber - ) - - bootstrap_owner!("conversation-degraded-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("conversation-degraded-owner@example.com", "owner-password-123") - - workspace_path = create_workspace_path!() - - {:ok, project} = - Project.create(%{ - name: "repo-conversation-degraded", - github_full_name: "owner/repo-conversation-degraded", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - }, - "execution" => %{ - "llm" => %{"provider" => "openai", "model" => "gpt-5-mini"} - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - on_exit(fn -> - case AgentWorkspace.latest_repo_conversation(managed_repo_id, actor: Actor.operator_actor()) do - {:ok, %{id: conversation_id}} -> - case AgentWorkspace.stop_conversation(conversation_id) do - :ok -> :ok - {:error, _reason} -> :ok - end - - _other -> - :ok - end - end) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=conversations", on_error: :warn) - - view - |> element("#project-detail-conversation-open") - |> render_click() - - assert has_element?(view, "#project-detail-conversation-degraded") - assert has_element?(view, "#project-detail-conversation-stream-mode", "Snapshot only") - - assert has_element?( - view, - "#project-detail-conversation-continuity-detail", - "live delivery is unavailable" - ) - - assert has_element?(view, "#project-detail-conversation-turn-state", "No active turn") - assert has_element?(view, "#project-detail-conversation-sequence-summary", "Sequence 0") - end - - test "hosts bounded semantic inspection inside the managed repo detail route", %{conn: _conn} do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - bootstrap_owner!("semantic-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("semantic-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("ProjectDetailSemantic.Alpha") - - {:ok, project} = - Project.create(%{ - name: "repo-semantic-ui", - github_full_name: "owner/repo-semantic-ui", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - assert {:ok, _load_result} = - AgentWorkspace.load_source_code_graph( - managed_repo_id, - workspace_path, - revision: "phase-25-ready" - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=semantic", on_error: :warn) - - assert has_element?(view, "#project-detail-semantic-inspection") - assert has_element?(view, "#project-detail-semantic-open-memory") - refute has_element?(view, "#project-detail-semantic-notice") - - vue = - assert_vue_component( - view, - "ProjectDetailSemanticExplorerWidget", - id: "project-detail-semantic-explorer-widget" - ) - - assert vue.props["managedRepoId"] == managed_repo_id - assert vue.props["graph"]["state"] == "ready" - assert Enum.any?(vue.props["summaryCards"], &(&1["id"] == "modules")) - assert Enum.any?(vue.props["modules"], &(&1["moduleName"] == "ProjectDetailSemantic.Alpha")) - - assert_vue_handler( - view, - "requestRecovery", - "recover_semantic_graph", - id: "project-detail-semantic-explorer-widget" - ) - end - - test "shows stale semantic status and lets operators recover from repo detail", %{conn: _conn} do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - bootstrap_owner!("semantic-stale-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("semantic-stale-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("ProjectDetailSemantic.Stale") - - {:ok, project} = - Project.create(%{ - name: "repo-semantic-stale", - github_full_name: "owner/repo-semantic-stale", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - assert {:ok, _load_result} = AgentWorkspace.load_source_code_graph(managed_repo_id, workspace_path) - - rewrite_semantic_workspace_module!(workspace_path, "ProjectDetailSemantic.Refreshed") - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=semantic", on_error: :warn) - - assert has_element?(view, "#project-detail-semantic-notice") - assert has_element?(view, "#project-detail-semantic-notice-type", "source_code_graph_stale") - assert has_element?(view, "#project-detail-semantic-recover", "Refresh semantic graph") - - view - |> element("#project-detail-semantic-recover") - |> render_click() - - assert has_element?(view, "#project-detail-semantic-feedback-type", "semantic_graph_recovered") - refute has_element?(view, "#project-detail-semantic-notice") - - vue = - assert_vue_component( - view, - "ProjectDetailSemanticExplorerWidget", - id: "project-detail-semantic-explorer-widget" - ) - - assert vue.props["graph"]["state"] == "ready" - end - - test "falls back to server-rendered semantic inspection when richer delivery degrades", %{conn: _conn} do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - bootstrap_owner!("semantic-fallback-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("semantic-fallback-owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - workspace_path = create_semantic_workspace_path!("ProjectDetailSemantic.Fallback") - - {:ok, project} = - Project.create(%{ - name: "repo-semantic-fallback", - github_full_name: "owner/repo-semantic-fallback", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - assert {:ok, _load_result} = - AgentWorkspace.load_source_code_graph(managed_repo_id, workspace_path) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=semantic", on_error: :warn) - - assert has_element?( - view, - "#project-detail-semantic-explorer-widget-fallback", - "Interactive semantic explorer temporarily unavailable" - ) - - assert has_element?( - view, - "#project-detail-semantic-explorer-widget-fallback", - "server-rendered semantic summary" - ) - - assert has_element?(view, "#project-detail-semantic-fallback") - assert has_element?(view, "#project-detail-semantic-fallback-modules") - assert has_element?(view, "#project-detail-semantic-fallback-impact-list") - end - - test "hosts bounded memory and provenance inspection inside the managed repo detail route", %{conn: _conn} do - Application.put_env(:jido_code, :memory_graph_enabled, true) - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - bootstrap_owner!("memory-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("ProjectDetailMemory.Alpha") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-ui", - github_full_name: "owner/repo-memory-ui", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - - seed_memory_graph!( - managed_repo_id, - workspace_path, - "phase-32-repo-detail", - module_name: "ProjectDetailMemory.Alpha" - ) - - assert {:ok, _load_result} = AgentWorkspace.load_source_code_graph(managed_repo_id, workspace_path) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=memory", on_error: :warn) - - assert has_element?(view, "#project-detail-memory-inspection") - assert has_element?(view, "#project-detail-memory-open-semantic") - assert has_element?(view, "#project-detail-memory-summary-memories") - assert has_element?(view, "#project-detail-memory-summary-provenance") - assert has_element?(view, "#project-detail-memory-list") - assert has_element?(view, "#project-detail-provenance-list") - assert has_element?(view, "#project-detail-memory-summary-memories") - end - - test "surfaces stale memory inspection with recovery on repo detail", %{conn: _conn} do - Application.put_env(:jido_code, :memory_graph_enabled, true) - - bootstrap_owner!("memory-stale-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-stale-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("ProjectDetailMemory.Stale") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-stale", - github_full_name: "owner/repo-memory-stale", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - seed_memory_graph!(managed_repo_id, workspace_path, "phase-32-repo-stale") - - assert {:ok, _invalidate_result} = - AgentWorkspace.invalidate_memory_graph( - managed_repo_id, - workspace_path, - reason: :manual_invalidation - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=memory", on_error: :warn) - - assert has_element?(view, "#project-detail-memory-notice") - assert has_element?(view, "#project-detail-memory-recover", "Validate memory graph") - - view - |> element("#project-detail-memory-recover") - |> render_click() - - assert has_element?(view, "#project-detail-memory-feedback-type", "memory_graph_recovered") - end - - test "repo detail memory keeps bounded conversation-origin recall cross-linked to canonical conversation routes", - %{conn: _conn} do - Application.put_env(:jido_code, :memory_graph_enabled, true) - - bootstrap_owner!("memory-recall-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-recall-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("ProjectDetailMemory.Recall") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-recall", - github_full_name: "owner/repo-memory-recall", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - managed_repo_id = managed_repo_route_id!(project.id) - seed_memory_graph!(managed_repo_id, workspace_path, "phase-76-repo-detail-recall") - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=memory", on_error: :warn) - - assert has_element?(view, "#project-detail-conversation-recall-list") - - assert has_element?( - view, - "#project-detail-conversation-recall-item-1-summary", - "Conversation requested clarification" - ) - - assert has_element?( - view, - "#project-detail-conversation-recall-item-1-preview", - "Which file or module should I inspect first?" - ) - - assert has_element?( - view, - "#project-detail-conversation-recall-item-1-conversation-link-1[href*='section=conversations'][href$='#project-detail-conversation-panel']", - "Open governed conversation" - ) - end - - defp create_workspace_path! do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido-code-project-workspace-#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf(workspace_path) end) - workspace_path - end - - defp managed_repo_route_id!(legacy_project_id) do - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(legacy_project_id, actor: Actor.operator_actor()) - - managed_repo.id - end - - defp create_semantic_workspace_path!(module_name) do - workspace_path = create_workspace_path!() - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule ProjectDetailSemantic.MixProject do - use Mix.Project - - def project do - [app: :project_detail_semantic, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - rewrite_semantic_workspace_module!(workspace_path, module_name) - workspace_path - end - - defp rewrite_semantic_workspace_module!(workspace_path, module_name) do - module_basename = - module_name - |> String.split(".") - |> List.last() - |> Macro.underscore() - - File.write!( - Path.join(workspace_path, "lib/#{module_basename}.ex"), - """ - defmodule #{module_name} do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - workspace_path - |> Path.join("lib/*.ex") - |> Path.wildcard() - |> Enum.reject(&String.ends_with?(&1, "#{module_basename}.ex")) - |> Enum.each(&File.rm!/1) - end - - defp seed_memory_graph!(managed_repo_id, workspace_path, revision, opts \\ []) do - module_name = Keyword.get(opts, :module_name, "ExampleWorkspace") - - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph(managed_repo_id, workspace_path, revision: revision) - - session_id = "detail-session-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:project-detail-memory", - workflow: :plan, - work_item_id: "work-32", - goal: "Seed project detail memory inspection" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _plan_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.plan( - session_id: session_id, - actor_id: "system:project-detail-memory", - workflow: :plan, - work_item_id: "work-32", - content: "Generated a bounded plan artifact for the repository.", - anchors: %{module_name: module_name} - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _conversation_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.conversation_turn( - session_id: session_id, - actor_id: "system:project-detail-memory", - workflow: :explain, - work_item_id: "work-32", - content: "Which file or module should I inspect first?", - revision: revision, - anchors: %{module_name: module_name}, - governed_context: %{run_id: "run-32", work_item_id: "work-32"}, - conversation_id: "conversation-32", - turn_id: "turn-32", - command_id: "command-32", - conversation_event: "clarification_requested", - clarification_state: "awaiting_input", - scope: "work_item_scoped", - attachment_mode: "synthesized_work_item", - status: "awaiting_input", - source: "repo_detail" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.decision( - session_id: session_id, - actor_id: "system:project-detail-memory", - workflow: :review, - work_item_id: "work-32", - content: "Repository decisions should keep #{module_name}.greet/1 stable.", - rationale: "Greeting behavior is used as a stable onboarding example.", - decision_status: :accepted, - revision: revision, - anchors: %{module_name: module_name}, - governed_context: %{run_id: "run-32", work_item_id: "work-32"}, - classification: %{ - source: "project_detail_test", - reason: "Repo detail memory inspection needs durable decision history." - } - ), - revision: revision - ) - end - - defp bootstrap_owner!(email, password) when is_binary(email) and is_binary(password) do - case User.bootstrap_admin( - %{ - email: email, - password: password, - password_confirmation: password - }, - authorize?: false - ) do - {:ok, _owner} -> - :ok - - {:error, reason} -> - if Exception.message(reason) =~ "has already been taken" do - :ok - else - raise "owner bootstrap failed: #{Exception.message(reason)}" - end - end - end - - defp latest_repo_conversation_id!(managed_repo_id) do - assert {:ok, %{id: conversation_id}} = - AgentWorkspace.latest_repo_conversation(managed_repo_id, actor: Actor.operator_actor()) - - conversation_id - end - - defp assert_conversation_settled!(conversation_id) do - assert_eventually(fn -> - case AgentWorkspace.conversation_snapshot(conversation_id) do - {:ok, snapshot} -> - is_nil(snapshot.active_turn) and is_nil(snapshot.active_child_work) - - _other -> - false - end - end) - end - - defp rendered_fragment_index(html, fragment) when is_binary(html) and is_binary(fragment) do - case :binary.match(html, fragment) do - {index, _length} -> index - :nomatch -> flunk("expected fragment #{inspect(fragment)} in rendered output") - end - end - - defp assert_eventually(fun, attempts \\ 20) - - defp assert_eventually(fun, attempts) when attempts <= 1 do - assert fun.() - end - - defp assert_eventually(fun, attempts) do - if fun.() do - assert true - else - receive do - after - 25 -> assert_eventually(fun, attempts - 1) - end - end - end -end diff --git a/test/jido_code_web/live/project_inventory_live_test.exs b/test/jido_code_web/live/project_inventory_live_test.exs deleted file mode 100644 index 17c73814..00000000 --- a/test/jido_code_web/live/project_inventory_live_test.exs +++ /dev/null @@ -1,168 +0,0 @@ -defmodule JidoCodeWeb.ProjectInventoryLiveTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - test "supports repo search and filtering on /repos and preserves query context in repo links", - %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: repo_alpha_id} = - provision_managed_repo!(%{ - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - default_branch: "main", - workspace_settings: %{} - }) - - %{route_id: repo_beta_id} = - provision_managed_repo!(%{ - name: "repo-beta", - github_full_name: "owner/repo-beta", - default_branch: "release", - workspace_settings: %{} - }) - - %{route_id: repo_gamma_id} = - provision_managed_repo!(%{ - name: "repo-gamma", - github_full_name: "owner/repo-gamma", - default_branch: "main", - workspace_settings: %{} - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos", on_error: :warn) - - assert has_element?(view, "#project-inventory-table") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_alpha_id}", "owner/repo-alpha") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_beta_id}", "owner/repo-beta") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_gamma_id}", "owner/repo-gamma") - - view - |> element("#project-inventory-filters-form") - |> render_change(%{ - "filters" => %{ - "search" => "beta", - "default_branch" => "release" - } - }) - - inventory_state_path = "/repos?search=beta&default_branch=release" - assert_patch(view, inventory_state_path) - - refute has_element?(view, "#project-inventory-github-full-name-#{repo_alpha_id}") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_beta_id}", "owner/repo-beta") - refute has_element?(view, "#project-inventory-github-full-name-#{repo_gamma_id}") - assert has_element?(view, "#project-inventory-results-count", "Showing 1 of 3") - - encoded_return_to = URI.encode_www_form(inventory_state_path) - - assert has_element?( - view, - "#project-inventory-open-#{repo_beta_id}[href='/repos/#{repo_beta_id}?return_to=#{encoded_return_to}']" - ) - end - - test "empty search query resets to default inventory results without error noise", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: repo_alpha_id} = - provision_managed_repo!(%{ - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - default_branch: "main", - workspace_settings: %{} - }) - - %{route_id: repo_beta_id} = - provision_managed_repo!(%{ - name: "repo-beta", - github_full_name: "owner/repo-beta", - default_branch: "release", - workspace_settings: %{} - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/repos", on_error: :warn) - - view - |> element("#project-inventory-filters-form") - |> render_change(%{ - "filters" => %{ - "search" => "alpha", - "default_branch" => "main" - } - }) - - assert_patch(view, "/repos?search=alpha&default_branch=main") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_alpha_id}", "owner/repo-alpha") - refute has_element?(view, "#project-inventory-github-full-name-#{repo_beta_id}") - - view - |> element("#project-inventory-filters-form") - |> render_change(%{ - "filters" => %{ - "search" => " ", - "default_branch" => "all" - } - }) - - assert_patch(view, "/repos") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_alpha_id}", "owner/repo-alpha") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_beta_id}", "owner/repo-beta") - assert has_element?(view, "#project-inventory-results-count", "Showing 2 of 2") - refute has_element?(view, "#project-inventory-filter-validation-notice") - end - - test "invalid query params reset inventory to defaults without error noise", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: repo_alpha_id} = - provision_managed_repo!(%{ - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - default_branch: "main", - workspace_settings: %{} - }) - - %{route_id: repo_beta_id} = - provision_managed_repo!(%{ - name: "repo-beta", - github_full_name: "owner/repo-beta", - default_branch: "release", - workspace_settings: %{} - }) - - invalid_path = - "/repos?" <> - URI.encode_query(%{ - "search" => "", - "default_branch" => "unknown-branch" - }) - - {:ok, view, _html} = live(recycle(authed_conn), invalid_path, on_error: :warn) - - assert has_element?(view, "#project-inventory-github-full-name-#{repo_alpha_id}", "owner/repo-alpha") - assert has_element?(view, "#project-inventory-github-full-name-#{repo_beta_id}", "owner/repo-beta") - assert has_element?(view, "#project-inventory-results-count", "Showing 2 of 2") - assert has_element?(view, "#project-inventory-filter-search[value='']") - - assert has_element?( - view, - "#project-inventory-filter-default-branch option[value='all'][selected]" - ) - - refute has_element?(view, "#project-inventory-filter-validation-notice") - end -end diff --git a/test/jido_code_web/live/run_detail_live_test.exs b/test/jido_code_web/live/run_detail_live_test.exs deleted file mode 100644 index 8c2f234f..00000000 --- a/test/jido_code_web/live/run_detail_live_test.exs +++ /dev/null @@ -1,3010 +0,0 @@ -defmodule JidoCodeWeb.RunDetailLiveTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.repo_posture.operator_surfaces_expose_explainable_governance_state - # covers: architecture.repo_posture.governed_run_memory_context_does_not_displace_posture_state - # covers: architecture.factory_control_plane.operator_surfaces_prefer_control_plane_records - # covers: architecture.run_governance.execution_projection_stays_internal_to_canonical_run_model - # covers: architecture.run_governance.run_detail_can_host_bounded_memory_context - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.runtime_service_overlay.operator_surfaces_keep_runtime_rollout_narratives_product_oriented - # covers: architecture.runtime_service_overlay.runtime_topology_details_remain_opaque_to_product - # covers: architecture.runtime_service_overlay.runtime_narratives_can_coexist_with_bounded_memory_context - # covers: architecture.source_code_graph_product_adoption.governed_surfaces_may_cohost_semantic_cross_links - # covers: architecture.source_code_graph_product_adoption.operator_surfaces_do_not_expose_raw_graph_internals - # covers: architecture.memory_graph_workflow_and_operator_expansion.cross_graph_navigation_connects_memory_code_and_governed_history - # covers: architecture.memory_graph_workflow_and_operator_expansion.governed_surfaces_host_memory_context - # covers: architecture.memory_graph_workflow_and_operator_expansion.memory_actions_preserve_freshness_supersession_and_provenance - # covers: architecture.memory_graph_workflow_and_operator_expansion.operator_memory_actions_use_product_owned_boundaries - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.{Actor, ManagedRepo, RepoBridge} - alias JidoCode.Governance.{Decision, Evidence, RepoPosture} - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope, GovernedSurfaceContext, ProductService} - alias JidoCode.Operations.{Assessment, Event, Ingress, WorkItem} - alias JidoCode.Orchestration.WorkflowRun - alias JidoCode.Projects.Project - - test "renders persisted status transition timeline entries with per-step durations", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail", - github_full_name: "owner/repo-run-detail", - default_branch: "main", - settings: %{} - }) - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: "run-detail-123", - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render transition timeline"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 22:00:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 22:01:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 22:02:00Z] - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/run-detail-123", on_error: :warn) - - assert has_element?(view, "#run-detail-title", "Run detail") - assert has_element?(view, "#run-detail-return-link[href='/repos/#{project.id}']", "Back to Repo detail") - assert has_element?(view, "#run-detail-run-id", "run-detail-123") - assert has_element?(view, "#run-detail-status", "awaiting_approval") - assert has_element?(view, "#run-detail-current-step", "approval_gate") - - assert has_element?( - view, - "#run-detail-conversation-unavailable", - "No governed work item is linked to this run yet" - ) - - assert has_element?(view, "#run-detail-timeline-entry-1") - assert has_element?(view, "#run-detail-timeline-transition-1", "pending") - assert has_element?(view, "#run-detail-timeline-step-1", "queued") - assert has_element?(view, "#run-detail-timeline-duration-1", "1m 0s") - assert has_element?(view, "#run-detail-timeline-at-1", "2026-02-14T22:00:00Z") - - assert has_element?(view, "#run-detail-timeline-entry-2") - assert has_element?(view, "#run-detail-timeline-transition-2", "running") - assert has_element?(view, "#run-detail-timeline-step-2", "plan_changes") - assert has_element?(view, "#run-detail-timeline-duration-2", "1m 0s") - assert has_element?(view, "#run-detail-timeline-at-2", "2026-02-14T22:01:00Z") - - assert has_element?(view, "#run-detail-timeline-entry-3") - assert has_element?(view, "#run-detail-timeline-transition-3", "awaiting_approval") - assert has_element?(view, "#run-detail-timeline-step-3", "approval_gate") - assert has_element?(view, "#run-detail-timeline-duration-3", "unknown") - assert has_element?(view, "#run-detail-timeline-at-3", "2026-02-14T22:02:00Z") - end - - test "uses explicit Workbench return paths for direct run detail entry", %{conn: _conn} do - register_owner("run-detail-workbench-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("run-detail-workbench-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-workbench-return", - github_full_name: "owner/repo-run-detail-workbench-return", - default_branch: "main", - settings: %{} - }) - - run_id = "run-detail-workbench-return-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Preserve Workbench return path"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "run-detail-workbench-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 22:00:00Z] - }) - - workbench_state_path = - "/workbench?project_id=#{project.id}&work_state=prs_open&freshness_window=active_24h" - - encoded_return_to = URI.encode_www_form(workbench_state_path) - - {:ok, view, _html} = - live( - recycle(authed_conn), - "/repos/#{project.id}/runs/#{run_id}?return_to=#{encoded_return_to}", - on_error: :warn - ) - - assert has_element?( - view, - "#run-detail-return-link[href='#{workbench_state_path}']", - "Back to Workbench" - ) - end - - test "updates timeline entries in near real time for active runs and marks missing duration as unknown", - %{conn: _conn} do - register_owner("timeline-live-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("timeline-live-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-live-timeline", - github_full_name: "owner/repo-run-detail-live-timeline", - default_branch: "main", - settings: %{} - }) - - run_id = "run-detail-live-timeline-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render near real-time timeline"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 22:10:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 22:11:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-status", "running") - assert has_element?(view, "#run-detail-timeline-transition-2", "running") - assert has_element?(view, "#run-detail-timeline-duration-2", "unknown") - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 22:12:00Z] - }) - - assert_eventually(fn -> - has_element?(view, "#run-detail-status", "awaiting_approval") and - has_element?(view, "#run-detail-timeline-transition-3", "awaiting_approval") and - has_element?(view, "#run-detail-timeline-duration-2", "1m 0s") and - has_element?(view, "#run-detail-timeline-duration-3", "unknown") - end) - end - - test "renders governed evidence and review state alongside workflow-run compatibility details", %{ - conn: _conn - } do - register_owner("governed-run-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("governed-run-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-governed-run-detail", - github_full_name: "owner/repo-governed-run-detail", - default_branch: "main", - settings: %{} - }) - - run_id = "run-governed-detail-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render governance panels"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "governed-run-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-03-31 21:00:00Z], - step_results: %{ - "diff_summary" => "2 files changed (+14/-2).", - "test_summary" => "mix test: 12 passed, 0 failed.", - "risk_notes" => ["Touches governed run presentation paths."], - "approval_context" => %{ - "diff_summary" => "2 files changed (+14/-2).", - "test_summary" => "mix test: 12 passed, 0 failed.", - "risk_notes" => ["Touches governed run presentation paths."] - } - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-03-31 21:01:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-03-31 21:02:00Z] - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(view, "#run-detail-current-stage", "approval") - assert has_element?(view, "#run-detail-evidence-list") - assert has_element?(view, "#run-detail-evidence-key-1") - assert has_element?(view, "#run-detail-change-request-status", "open") - assert has_element?(view, "#run-detail-decisions-empty") - end - - test "shows productive conversation lineage for governed run work items", %{conn: _conn} do - register_owner("run-conversation-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("run-conversation-owner@example.com", "owner-password-123") - - workspace_path = create_memory_workspace_path!("run_detail_conversation_linkage") - - {:ok, project} = - Project.create(%{ - name: "repo-run-conversation-linkage", - github_full_name: "owner/repo-run-conversation-linkage", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - on_exit(fn -> - case AgentWorkspace.active_work_item_conversations(managed_repo.id, actor: Actor.operator_actor()) do - {:ok, conversations} -> - Enum.each(conversations, fn conversation -> - _ = AgentWorkspace.stop_conversation(conversation.id) - end) - - _other -> - :ok - end - - _ = AgentWorkspace.shutdown_kernel(managed_repo.id) - :ok - end) - - {:ok, detail_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}?section=conversations", on_error: :warn) - - detail_view - |> element("#project-detail-conversation-open") - |> render_click() - - detail_view - |> form("#project-detail-conversation-form", %{ - "input" => "Inspect the repo detail conversation flow." - }) - |> render_submit() - - assert_eventually(fn -> - with {:ok, %{id: conversation_id}} <- - AgentWorkspace.latest_repo_conversation(managed_repo.id, actor: Actor.operator_actor()), - {:ok, snapshot} <- AgentWorkspace.conversation_snapshot(conversation_id) do - is_binary(snapshot.work_item_id) - else - _other -> false - end - end) - - {:ok, %{id: conversation_id}} = - AgentWorkspace.latest_repo_conversation(managed_repo.id, actor: Actor.operator_actor()) - - {:ok, snapshot} = AgentWorkspace.conversation_snapshot(conversation_id) - work_item_id = snapshot.work_item_id - - run_id = "run-conversation-linkage-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - managed_repo_id: managed_repo.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{ - "task_summary" => "Render conversation lineage", - "work_item_id" => work_item_id - }, - input_metadata: %{ - "task_summary" => %{required: true, source: "manual_workflows_ui"}, - "work_item_id" => %{required: true, source: "conversation"} - }, - initiating_actor: %{id: "owner-1", email: "run-conversation-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-12 19:00:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-12 19:01:00Z] - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - encoded_dashboard_return_to = - URI.encode_www_form("/dashboard?subject=work§ion=overview") - - assert has_element?(view, "#run-detail-conversation-entry") - assert has_element?(view, "#run-detail-conversation-role", "Governed conversation") - assert has_element?(view, "#run-detail-conversation-status", "active") - assert has_element?(view, "#run-detail-conversation-resolution", "created") - - assert has_element?( - view, - "#run-detail-conversation-open-repo[href='/repos/#{project.id}?return_to=#{encoded_dashboard_return_to}§ion=conversations&work_item_id=#{work_item_id}#project-detail-conversation-panel']", - "Resume governed conversation" - ) - end - - test "shows current and historical governed conversation lineage when a work item reopens", %{ - conn: _conn - } do - register_owner("run-conversation-history-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("run-conversation-history-owner@example.com", "owner-password-123") - - workspace_path = create_memory_workspace_path!("run_detail_conversation_history") - - {:ok, project} = - Project.create(%{ - name: "repo-run-conversation-history", - github_full_name: "owner/repo-run-conversation-history", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, tracked_conversations} = Agent.start(fn -> [] end) - - on_exit(fn -> - if Process.alive?(tracked_conversations) do - tracked_conversations - |> Agent.get(&Enum.uniq(&1)) - |> Enum.each(fn conversation_id -> - _ = AgentWorkspace.stop_conversation(conversation_id) - end) - - Agent.stop(tracked_conversations) - end - - _ = AgentWorkspace.shutdown_kernel(managed_repo.id) - :ok - end) - - first_work_item = work_item_fixture!(managed_repo, "run-detail-history-first") - second_work_item = work_item_fixture!(managed_repo, "run-detail-history-second") - - assert {:ok, %{conversation: historical_conversation}} = - AgentWorkspace.open_work_item_conversation( - first_work_item.id, - %{ - source: "run_detail_live_test", - objective: "Coordinate the first governed work item through conversation." - }, - actor: Actor.operator_actor(%{"id" => "run-detail-history-first-open"}) - ) - - Agent.update(tracked_conversations, &[historical_conversation.id | &1]) - - assert {:ok, %{conversation: second_conversation}} = - AgentWorkspace.open_work_item_conversation( - second_work_item.id, - %{ - source: "run_detail_live_test", - objective: "Keep a second governed conversation active in the same repository." - }, - actor: Actor.operator_actor(%{"id" => "run-detail-history-second-open"}) - ) - - Agent.update(tracked_conversations, &[second_conversation.id | &1]) - - assert {:ok, completed_work_item} = - WorkItem.update(first_work_item, %{status: :completed}, actor: Actor.operator_actor()) - - assert {:ok, %{active_conversation: nil, settled_conversation: settled_conversation}} = - JidoCode.Conversations.reconcile_work_item_conversation_lifecycle( - completed_work_item.id, - actor: Actor.operator_actor() - ) - - assert settled_conversation.id == historical_conversation.id - - assert {:ok, reopened_work_item} = - WorkItem.update(completed_work_item, %{status: :open}, actor: Actor.operator_actor()) - - assert {:ok, %{conversation: current_conversation}} = - AgentWorkspace.open_work_item_conversation( - reopened_work_item.id, - %{ - source: "run_detail_live_test", - objective: "Resume the reopened governed work item." - }, - actor: Actor.operator_actor(%{"id" => "run-detail-history-first-reopen"}) - ) - - Agent.update(tracked_conversations, &[current_conversation.id | &1]) - - run_id = "run-conversation-history-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - managed_repo_id: managed_repo.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{ - "task_summary" => "Render historical conversation lineage", - "work_item_id" => reopened_work_item.id - }, - input_metadata: %{ - "task_summary" => %{required: true, source: "manual_workflows_ui"}, - "work_item_id" => %{required: true, source: "conversation"} - }, - initiating_actor: %{id: "owner-1", email: "run-conversation-history-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-15 10:00:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-15 10:01:00Z] - }) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - encoded_dashboard_return_to = - URI.encode_www_form("/dashboard?subject=work§ion=overview") - - assert has_element?(view, "#run-detail-conversation-entry") - assert has_element?(view, "#run-detail-conversation-role", "Governed conversation") - assert has_element?(view, "#run-detail-conversation-id", current_conversation.id) - assert has_element?(view, "#run-detail-conversation-status", "active") - assert has_element?(view, "#run-detail-conversation-lineage-note", historical_conversation.id) - assert has_element?(view, "#run-detail-conversation-historical-role", "Historical lineage") - assert has_element?(view, "#run-detail-conversation-historical-id", historical_conversation.id) - - assert has_element?( - view, - "#run-detail-conversation-open-repo[href='/repos/#{project.id}?return_to=#{encoded_dashboard_return_to}§ion=conversations&work_item_id=#{reopened_work_item.id}#project-detail-conversation-panel']", - "Resume governed conversation" - ) - end - - test "shows bounded memory context for governed run history", %{conn: _conn} do - previous = Application.get_env(:jido_code, :memory_graph_enabled, false) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, previous) - end) - - register_owner("memory-run-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-run-owner@example.com", "owner-password-123") - - workspace_path = create_memory_workspace_path!("run_detail_memory_context") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-run-detail", - github_full_name: "owner/repo-memory-run-detail", - default_branch: "main", - settings: %{workspace: %{workspace_path: workspace_path}} - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, event} = - Event.create( - %{ - managed_repo_id: managed_repo.id, - category: "memory_graph_review_requested", - summary: "Memory graph review was requested for a governed run.", - correlation_key: "run-memory-detail-#{System.unique_integer([:positive])}", - payload: %{}, - source_metadata: %{"source" => "run_detail_live_test"}, - occurred_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, assessment} = - Assessment.create( - %{ - managed_repo_id: managed_repo.id, - event_id: event.id, - category: "memory_review", - summary: "Assess durable memory follow-up for the governed run.", - priority: :medium, - urgency: :medium, - recommended_action: "review_memory_context", - rationale: "Phase 34 rollout should show bounded work-item memory context.", - inputs: %{}, - assessment_metadata: %{}, - assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, work_item} = - WorkItem.create( - %{ - managed_repo_id: managed_repo.id, - assessment_id: assessment.id, - event_id: event.id, - category: "memory_review", - status: :open, - priority: :medium, - recommended_action: "review_memory_context", - summary: "Review memory context linked to the governed run.", - dedup_key: "run-memory-detail-work-item-#{System.unique_integer([:positive])}", - initiating_actor: %{"id" => "owner-1"}, - work_metadata: %{}, - audit_log: [], - opened_at: DateTime.utc_now() |> DateTime.truncate(:microsecond), - last_assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - run_id = "run-memory-detail-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render memory context"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "memory-run-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-10 19:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-10 19:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-10 19:02:00Z] - }) - - {:ok, run} = - JidoCode.Orchestration.Run.get_by_managed_repo_and_run_id( - managed_repo.id, - run_id, - actor: Actor.operator_actor() - ) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - key: "memory_history", - evidence_type: "memory_graph_finding", - summary: "Memory context was recorded for this governed run.", - evidence_details: %{"source" => "run_detail_live_test"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "memory-run-#{run.id}", - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - decision: :defer, - actor: %{"id" => "owner-1", "email" => "memory-run-owner@example.com"}, - rationale: "Memory context should stay reviewable on the run route.", - decision_metadata: %{"source" => "run_detail_live_test"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - revision = revision_metadata.current_revision - - seed_run_memory_context!( - managed_repo.id, - workspace_path, - revision, - run_id, - evidence.id, - decision.id, - work_item_id: work_item.id - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - rendered = render(view) - - assert has_element?(view, "#run-detail-memory-context") - assert has_element?(view, "#run-detail-memory-context-state", "ready") - assert has_element?(view, "#run-detail-work-item-entry") - assert has_element?(view, "#run-detail-memory-work-item-history") - assert has_element?(view, "#run-detail-work-item-memory", "Work item memory context") - assert has_element?(view, "#run-detail-evidence-memory-contexts", "Evidence memory context") - assert has_element?(view, "#run-detail-decision-memory-contexts", "Decision memory context") - assert has_element?(view, "#run-detail-memory-1-governed-label", "Governed context") - assert has_element?(view, "#run-detail-evidence-memory-#{evidence.id}-memory-1-governed-label", "Governed context") - assert rendered =~ "memory_history" - assert rendered =~ "defer" - end - - test "surfaces stale run memory context with bounded recovery on the canonical run route", %{ - conn: _conn - } do - previous = Application.get_env(:jido_code, :memory_graph_enabled, false) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, previous) - end) - - register_owner("memory-run-recovery-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-run-recovery-owner@example.com", "owner-password-123") - - workspace_path = create_memory_workspace_path!("run_detail_memory_recovery") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-run-recovery", - github_full_name: "owner/repo-memory-run-recovery", - default_branch: "main", - settings: %{workspace: %{workspace_path: workspace_path}} - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, event} = - Event.create( - %{ - managed_repo_id: managed_repo.id, - category: "memory_graph_recovery_requested", - summary: "Recovery was requested for stale run memory context.", - correlation_key: "run-memory-recovery-#{System.unique_integer([:positive])}", - payload: %{}, - source_metadata: %{"source" => "run_detail_live_test"}, - occurred_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, assessment} = - Assessment.create( - %{ - managed_repo_id: managed_repo.id, - event_id: event.id, - category: "memory_recovery", - summary: "Assess stale governed memory recovery for this run.", - priority: :medium, - urgency: :medium, - recommended_action: "recover_memory_context", - rationale: "Run detail should expose bounded recovery for invalidated memory state.", - inputs: %{}, - assessment_metadata: %{}, - assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, work_item} = - WorkItem.create( - %{ - managed_repo_id: managed_repo.id, - assessment_id: assessment.id, - event_id: event.id, - summary: "Recover stale run memory context", - status: :open, - category: "review_follow_up", - priority: :medium, - recommended_action: "recover_memory_context", - dedup_key: "run-memory-recovery-work-item-#{System.unique_integer([:positive])}", - initiating_actor: %{"id" => "owner-3"}, - work_metadata: %{}, - audit_log: [], - opened_at: DateTime.utc_now() |> DateTime.truncate(:microsecond), - last_assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - run_id = "run-memory-recovery-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Recover stale run memory context"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-3", email: "memory-run-recovery-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-10 21:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "review_memory", - transitioned_at: ~U[2026-04-10 21:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-10 21:02:00Z] - }) - - {:ok, run} = - JidoCode.Orchestration.Run.get_by_managed_repo_and_run_id( - managed_repo.id, - run_id, - actor: Actor.operator_actor() - ) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - key: "memory_recovery", - evidence_type: "memory_graph_finding", - summary: "Run memory recovery should stay product-owned.", - evidence_details: %{"source" => "run_detail_live_test"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "memory-recovery-run-#{run.id}", - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - decision: :approve, - actor: %{"id" => "owner-3", "email" => "memory-run-recovery-owner@example.com"}, - rationale: "Run detail should expose governed memory recovery without leaving the route.", - decision_metadata: %{"source" => "run_detail_live_test"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - revision = revision_metadata.current_revision - - seed_run_memory_context!( - managed_repo.id, - workspace_path, - revision, - run_id, - evidence.id, - decision.id, - work_item_id: work_item.id - ) - - assert {:ok, _invalidate_result} = - AgentWorkspace.invalidate_memory_graph( - managed_repo.id, - workspace_path, - reason: :manual_invalidation - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(view, "#run-detail-memory-context-notice") - assert has_element?(view, "#run-detail-memory-recover", "Validate memory graph") - - view - |> element("#run-detail-memory-recover") - |> render_click() - - assert has_element?(view, "#run-detail-memory-action-feedback-type", "memory_graph_recovered") - end - - test "supports bounded operator memory actions from the governed run surface", %{conn: _conn} do - previous = Application.get_env(:jido_code, :memory_graph_enabled, false) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, previous) - end) - - register_owner("memory-run-operator@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-run-operator@example.com", "owner-password-123") - - workspace_path = create_memory_workspace_path!("run_detail_memory_operator_actions") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-run-operator", - github_full_name: "owner/repo-memory-run-operator", - default_branch: "main", - settings: %{workspace: %{workspace_path: workspace_path}} - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - run_id = "run-memory-operator-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render memory operator actions"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-2", email: "memory-run-operator@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-10 20:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-10 20:00:30Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-10 20:01:00Z] - }) - - {:ok, run} = - JidoCode.Orchestration.Run.get_by_managed_repo_and_run_id( - managed_repo.id, - run_id, - actor: Actor.operator_actor() - ) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo.id, - key: "memory_operator_history", - evidence_type: "memory_graph_finding", - summary: "Memory operator actions should stay bounded.", - evidence_details: %{"source" => "run_detail_live_test"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "memory-operator-run-#{run.id}", - run_id: run.id, - managed_repo_id: managed_repo.id, - decision: :approve, - actor: %{"id" => "owner-2", "email" => "memory-run-operator@example.com"}, - rationale: "The latest governed decision can supersede older durable decision memory.", - decision_metadata: %{"source" => "run_detail_live_test"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - revision = revision_metadata.current_revision - - seed_run_memory_context!( - managed_repo.id, - workspace_path, - revision, - run.run_id, - evidence.id, - decision.id, - include_decision_memory?: true, - link_known_issue_to_decision?: false - ) - - assert {:ok, memory_projection} = - ProductService.memories_for_governed_artifacts( - managed_repo.id, - workspace_path, - [ - MemoryGraph.artifact_path(:run, run.run_id), - MemoryGraph.artifact_path(:evidence, evidence.id), - MemoryGraph.artifact_path(:decision, decision.id) - ], - revision: revision - ) - - assert memory_projection.items != [] - - assert {:ok, project_scope} = RepoBridge.repo_scope(project.id) - - memory_context = - GovernedSurfaceContext.load_run_detail( - project_scope, - run, - [evidence], - [decision], - revision: revision, - managed_repo_id: managed_repo.id, - workspace_path: workspace_path - ) - - assert memory_context.graph.state == :ready - assert memory_context.graph.ready? == true - assert memory_context.memories.items != [] - - assert memory_context.governed_surfaces.decisions != [] - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(view, "#run-detail-memory-context") - assert has_element?(view, "#run-detail-memory-list") - assert has_element?(view, "#run-detail-evidence-memory-#{evidence.id}-memory-list") - assert has_element?(view, "#run-detail-decision-memory-#{decision.id}-memory-list") - assert has_element?(view, "#run-detail-memory-1-governed-label", "Governed context") - assert has_element?(view, "#run-detail-memory-follow-up-preview") - assert has_element?(view, "#run-detail-memory-follow-up-preview-summary") - assert render(view) =~ "Validate" - assert render(view) =~ "Create follow-up" - assert has_element?(view, "#run-detail-memory-validate-1") - assert has_element?(view, "#run-detail-memory-promote-1") - - render_click(element(view, "#run-detail-evidence-memory-#{evidence.id}-memory-validate-1")) - - assert has_element?( - view, - "#run-detail-memory-action-feedback", - "Recorded durable memory validation" - ) - - render_click(element(view, "#run-detail-evidence-memory-#{evidence.id}-memory-promote-1")) - assert has_element?(view, "#run-detail-memory-action-feedback", "Created governed follow-up work item") - - render_click( - element( - view, - "#run-detail-decision-memory-supersede-#{decision.id}" - ) - ) - - assert has_element?( - view, - "#run-detail-memory-action-feedback", - "Superseded durable decision memory with the latest governed decision" - ) - - {:ok, work_items} = - WorkItem.read( - query: [filter: [managed_repo_id: managed_repo.id]], - actor: Actor.operator_actor() - ) - - assert length(work_items) >= 1 - end - - test "run detail memory keeps bounded conversation-origin recall cross-linked to canonical repo conversations", - %{conn: _conn} do - previous = Application.get_env(:jido_code, :memory_graph_enabled, false) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - on_exit(fn -> - Application.put_env(:jido_code, :memory_graph_enabled, previous) - end) - - register_owner("memory-run-recall@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("memory-run-recall@example.com", "owner-password-123") - - workspace_path = create_memory_workspace_path!("run_detail_conversation_recall") - - {:ok, project} = - Project.create(%{ - name: "repo-memory-run-recall", - github_full_name: "owner/repo-memory-run-recall", - default_branch: "main", - settings: %{workspace: %{workspace_path: workspace_path}} - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, event} = - Event.create( - %{ - managed_repo_id: managed_repo.id, - category: "conversation_origin_context", - summary: "Conversation-origin recall should route back through canonical conversations.", - correlation_key: "run-memory-recall-event-#{System.unique_integer([:positive])}", - payload: %{}, - source_metadata: %{"source" => "run_detail_live_test"}, - occurred_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, assessment} = - Assessment.create( - %{ - managed_repo_id: managed_repo.id, - event_id: event.id, - category: "memory_recall", - summary: "Review bounded conversation-origin recall for this run.", - priority: :medium, - urgency: :medium, - recommended_action: "review_conversation_recall", - rationale: "Run detail should keep origin context bounded and routed back to canonical conversations.", - inputs: %{}, - assessment_metadata: %{}, - assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - {:ok, work_item} = - WorkItem.create( - %{ - managed_repo_id: managed_repo.id, - assessment_id: assessment.id, - event_id: event.id, - summary: "Review conversation-derived origin context", - status: :open, - category: "review_follow_up", - priority: :medium, - recommended_action: "review_conversation_recall", - dedup_key: "run-memory-recall-work-item-#{System.unique_integer([:positive])}", - initiating_actor: %{"id" => "owner-4"}, - work_metadata: %{}, - audit_log: [], - opened_at: DateTime.utc_now() |> DateTime.truncate(:microsecond), - last_assessed_at: DateTime.utc_now() |> DateTime.truncate(:microsecond) - }, - actor: Actor.operator_actor() - ) - - run_id = "run-memory-recall-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render bounded conversation recall"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-4", email: "memory-run-recall@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-12 20:00:00Z] - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "review_memory", - transitioned_at: ~U[2026-04-12 20:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-12 20:02:00Z] - }) - - {:ok, run} = - JidoCode.Orchestration.Run.get_by_managed_repo_and_run_id( - managed_repo.id, - run_id, - actor: Actor.operator_actor() - ) - - {:ok, evidence} = - Evidence.create( - %{ - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - key: "conversation_origin_context", - evidence_type: "memory_graph_finding", - summary: "Bounded conversation-origin recall should stay routed through canonical conversations.", - evidence_details: %{"source" => "run_detail_live_test"}, - source: "memory_graph", - recorded_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, decision} = - Decision.create( - %{ - decision_key: "memory-recall-run-#{run.id}", - run_id: run.id, - managed_repo_id: managed_repo.id, - work_item_id: work_item.id, - decision: :approve, - actor: %{"id" => "owner-4", "email" => "memory-run-recall@example.com"}, - rationale: "Use bounded conversation-origin recall here and reopen the canonical conversation route for full continuity.", - decision_metadata: %{"source" => "run_detail_live_test"}, - decided_at: DateTime.utc_now() - }, - actor: Actor.operator_actor() - ) - - {:ok, revision_metadata} = MemoryGraph.current_revision_metadata(workspace_path) - revision = revision_metadata.current_revision - - seed_run_memory_context!( - managed_repo.id, - workspace_path, - revision, - run.run_id, - evidence.id, - decision.id, - work_item_id: work_item.id - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - assert has_element?(view, "#run-detail-conversation-recall-list") - assert has_element?(view, "#run-detail-conversation-recall-1-summary", "Conversation requested clarification") - assert has_element?(view, "#run-detail-conversation-follow-up-preview") - - assert has_element?( - view, - "#run-detail-conversation-recall-1-conversation-link-1[href*='section=conversations'][href$='#project-detail-conversation-panel']", - "Open governed conversation" - ) - - assert has_element?( - view, - "#run-detail-conversation-follow-up-preview-route[href*='section=conversations'][href$='#project-detail-conversation-panel']" - ) - end - - test "renders bounded runtime evidence using product-oriented posture language", %{conn: _conn} do - register_owner("runtime-run-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("runtime-run-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-runtime-run-detail", - github_full_name: "owner/repo-runtime-run-detail", - default_branch: "main", - settings: %{} - }) - - run_id = "run-runtime-detail-#{System.unique_integer([:positive])}" - - {:ok, workflow_run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 1, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render runtime evidence"}, - input_metadata: %{"task_summary" => %{required: true, source: "test"}}, - initiating_actor: %{id: "owner-1", email: "runtime-run-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-04-01 12:00:00Z], - step_results: %{ - "diff_summary" => "2 files changed (+9/-1).", - "runtime_service_delivery" => %{ - "delivery_mode" => "replay_recovery", - "reason_code" => "live_delivery_detached", - "terminal_handoff_kind" => "replay_terminal_lookup", - "terminal_state" => "completed", - "summary" => "Runtime delivery repaired through replay recovery." - } - } - }) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-04-01 12:01:00Z] - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-04-01 12:02:00Z] - }) - - {:ok, managed_repo} = - ManagedRepo.get_by_legacy_project_id(project.id, actor: Actor.operator_actor()) - - {:ok, _repo_posture} = - RepoPosture.upsert_for_managed_repo( - %{ - managed_repo_id: managed_repo.id, - summary: "Repo posture remains governed.", - overall_trust: "medium", - execution_readiness: "medium", - validation_reliability: "high", - review_burden: "high", - drift_rate: "low", - recovery_resilience: "medium", - requirements_confidence: "high", - supervision_mode: "guided", - escalation_status: "review", - contributing_check_ids: [], - posture_metadata: %{ - "runtime_service_evidence_summary" => - "Runtime evidence requires operator review before execution trust can be restored.", - "runtime_service_evidence_state" => %{ - "status" => "degraded", - "review_required" => true, - "runtime_delivery" => %{ - "delivery_mode" => "replay_recovery", - "reason_code" => "live_delivery_detached" - }, - "integration_outcomes" => %{ - "latest_invocation" => %{ - "provider" => "github", - "summary" => "github installation delivery recovered" - } - } - } - } - }, - actor: Actor.operator_actor() - ) - - {:ok, view, _html} = - live(recycle(authed_conn), ~p"/repos/#{project.id}/runs/#{run_id}", on_error: :warn) - - vue = - assert_vue_component( - view, - "RunGovernanceOverviewWidget", - id: "run-detail-governance-overview-widget" - ) - - assert vue.props["runStatus"] == "awaiting_approval" - assert vue.props["runtimeEvidence"]["statusLabel"] == "review required" - assert vue.props["evidenceCount"] == 3 - assert vue.props["decisionCount"] == 0 - - assert has_element?(view, "#run-detail-runtime-evidence") - assert has_element?(view, "#run-detail-runtime-evidence-status", "review required") - - assert has_element?( - view, - "#run-detail-runtime-evidence-summary", - "operator review before execution trust can be restored" - ) - - assert has_element?( - view, - "#run-detail-runtime-evidence-delivery-mode", - "replay recovery" - ) - - assert has_element?( - view, - "#run-detail-runtime-evidence-reason", - "live delivery detached" - ) - - assert has_element?( - view, - "#run-detail-runtime-evidence-integration", - "github installation delivery recovered" - ) - - assert has_element?(view, "#run-detail-runtime-evidence-note", "Product governance stores bounded runtime evidence") - end - - test "renders run artifact browser categories with stable view identifiers", %{conn: _conn} do - register_owner("artifact-browser-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("artifact-browser-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-artifact-browser", - github_full_name: "owner/repo-run-detail-artifact-browser", - default_branch: "main", - settings: %{} - }) - - run_id = "run-detail-artifact-browser-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render artifact browser"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "artifact-browser-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 06:45:00Z], - step_results: %{ - "run_logs" => [ - %{ - "event" => "step_started", - "message" => "Preparing implementation branch." - } - ], - "diff_summary" => "3 files changed (+12/-3).", - "failure_report" => %{ - "step" => "run_tests", - "summary" => "1 test failed in CI." - }, - "pull_request" => %{ - "number" => 451, - "url" => "https://github.com/owner/repo-run-detail-artifact-browser/pull/451", - "head_branch" => "jidocode/implement-task/run-abc123" - } - } - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-artifact-browser") - assert has_element?(view, "#run-detail-artifact-category-title-logs", "Logs") - - assert has_element?( - view, - "#run-detail-artifact-category-title-diff_summaries", - "Diff summaries" - ) - - assert has_element?(view, "#run-detail-artifact-category-title-reports", "Reports") - assert has_element?(view, "#run-detail-artifact-category-title-pr_metadata", "PR metadata") - - assert has_element?(view, "#run-detail-artifact-entry-logs-run-logs") - assert has_element?(view, "#run-detail-artifact-view-logs-run-logs", "View artifact") - assert has_element?(view, "#run-detail-artifact-source-logs-run-logs", "run_logs") - - assert has_element?(view, "#run-detail-artifact-entry-diff-summaries-diff-summary") - - assert has_element?( - view, - "#run-detail-artifact-view-diff-summaries-diff-summary", - "View artifact" - ) - - assert has_element?(view, "#run-detail-artifact-entry-reports-failure-report") - assert has_element?(view, "#run-detail-artifact-view-reports-failure-report", "View artifact") - - assert has_element?(view, "#run-detail-artifact-entry-pr-metadata-pull-request") - - assert has_element?( - view, - "#run-detail-artifact-view-pr-metadata-pull-request", - "View artifact" - ) - - assert has_element?( - view, - "#run-detail-artifact-payload-content-pr-metadata-pull-request", - "pull/451" - ) - - refute has_element?(view, "#run-detail-artifact-category-missing-logs") - refute has_element?(view, "#run-detail-artifact-category-missing-diff_summaries") - refute has_element?(view, "#run-detail-artifact-category-missing-reports") - refute has_element?(view, "#run-detail-artifact-category-missing-pr_metadata") - end - - test "shows missing artifact status per category when artifact records are unavailable", %{ - conn: _conn - } do - register_owner("artifact-missing-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("artifact-missing-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-artifact-missing", - github_full_name: "owner/repo-run-detail-artifact-missing", - default_branch: "main", - settings: %{} - }) - - run_id = "run-detail-artifact-missing-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render missing artifact states"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "artifact-missing-owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 06:50:00Z], - step_results: %{ - "diff_summary" => "1 file changed (+2/-0)." - } - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-title", "Run detail") - assert has_element?(view, "#run-detail-artifact-browser") - assert has_element?(view, "#run-detail-artifact-entry-diff-summaries-diff-summary") - refute has_element?(view, "#run-detail-artifact-category-missing-diff_summaries") - - assert has_element?( - view, - "#run-detail-artifact-category-missing-logs", - "Missing artifact records for this category." - ) - - assert has_element?( - view, - "#run-detail-artifact-category-missing-reports", - "Missing artifact records for this category." - ) - - assert has_element?( - view, - "#run-detail-artifact-category-missing-pr_metadata", - "Missing artifact records for this category." - ) - end - - test "renders issue triage artifact set for issue_triage workflow runs", %{conn: _conn} do - register_owner("issue-triage-artifacts-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("issue-triage-artifacts-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-issue-triage-artifacts", - github_full_name: "owner/repo-run-detail-issue-triage-artifacts", - default_branch: "main", - settings: %{} - }) - - run_id = "run-detail-issue-triage-artifacts-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "issue_triage", - workflow_version: 1, - trigger: %{ - source: "github_webhook", - mode: "webhook", - source_issue: %{"number" => 91, "id" => 91_001} - }, - inputs: %{"issue_reference" => "owner/repo-run-detail-issue-triage-artifacts#91"}, - input_metadata: %{ - "issue_reference" => %{ - "required" => true, - "source" => "github_webhook", - "source_issue" => %{"number" => 91, "id" => 91_001} - } - }, - initiating_actor: %{id: "github_webhook", email: nil}, - current_step: "queued", - started_at: ~U[2026-02-15 06:00:00Z], - step_results: %{ - "run_issue_triage" => %{ - "classification" => "bug", - "summary" => "Issue triage classified this report as bug.", - "linked_run" => %{ - "run_id" => run_id, - "workflow_name" => "issue_triage", - "issue_reference" => "owner/repo-run-detail-issue-triage-artifacts#91", - "source_issue" => %{"number" => 91, "id" => 91_001} - } - }, - "run_issue_research" => %{ - "summary" => "Initial research summary for issue reproduction and root-cause direction.", - "linked_run" => %{ - "run_id" => run_id, - "workflow_name" => "issue_triage", - "issue_reference" => "owner/repo-run-detail-issue-triage-artifacts#91", - "source_issue" => %{"number" => 91, "id" => 91_001} - } - }, - "compose_issue_response" => %{ - "proposed_response" => "Thanks for the report. We triaged this as bug and prepared a response draft.", - "linked_run" => %{ - "run_id" => run_id, - "workflow_name" => "issue_triage", - "issue_reference" => "owner/repo-run-detail-issue-triage-artifacts#91", - "source_issue" => %{"number" => 91, "id" => 91_001} - } - }, - "issue_bot_artifact_lineage" => %{ - "status" => "persisted", - "artifact_keys" => [ - "compose_issue_response", - "run_issue_research", - "run_issue_triage" - ], - "linked_run" => %{ - "run_id" => run_id, - "workflow_name" => "issue_triage", - "issue_reference" => "owner/repo-run-detail-issue-triage-artifacts#91", - "source_issue" => %{"number" => 91, "id" => 91_001} - } - }, - "post_issue_response" => %{ - "status" => "posted", - "provider" => "github", - "posted" => true, - "approval_mode" => "auto_post", - "approval_decision" => "auto_approved", - "comment_url" => - "https://github.com/owner/repo-run-detail-issue-triage-artifacts/issues/91#issuecomment-91001", - "comment_id" => 91_001, - "posted_at" => "2026-02-15T06:02:00Z" - } - } - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-issue-triage-artifacts") - assert has_element?(view, "#run-detail-issue-artifact-persistence-status", "persisted") - assert has_element?(view, "#run-detail-issue-triage-classification", "bug") - - assert has_element?( - view, - "#run-detail-issue-research-summary", - "Initial research summary for issue reproduction and root-cause direction." - ) - - assert has_element?( - view, - "#run-detail-issue-response-draft", - "We triaged this as bug and prepared a response draft." - ) - - assert has_element?(view, "#run-detail-issue-response-post-status", "posted") - - assert has_element?( - view, - "#run-detail-issue-response-post-url", - "https://github.com/owner/repo-run-detail-issue-triage-artifacts/issues/91#issuecomment-91001" - ) - - assert has_element?(view, "#run-detail-issue-response-post-comment-id", "91001") - assert has_element?(view, "#run-detail-issue-response-posted-at", "2026-02-15T06:02:00Z") - - assert has_element?( - view, - "#run-detail-issue-artifact-issue-reference", - "owner/repo-run-detail-issue-triage-artifacts#91" - ) - - assert has_element?(view, "#run-detail-issue-artifact-source-issue-number", "91") - assert has_element?(view, "#run-detail-issue-artifact-run-id", run_id) - refute has_element?(view, "#run-detail-issue-artifact-persistence-error") - refute has_element?(view, "#run-detail-issue-response-post-error") - end - - test "renders typed Issue Bot response post failure artifact details", %{conn: _conn} do - register_owner("issue-triage-post-failure-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("issue-triage-post-failure-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-issue-triage-post-failure", - github_full_name: "owner/repo-run-detail-issue-triage-post-failure", - default_branch: "main", - settings: %{} - }) - - run_id = "run-detail-issue-triage-post-failure-#{System.unique_integer([:positive])}" - - {:ok, _run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: run_id, - workflow_name: "issue_triage", - workflow_version: 1, - trigger: %{ - source: "github_webhook", - mode: "webhook", - source_issue: %{"number" => 102, "id" => 102_001} - }, - inputs: %{"issue_reference" => "owner/repo-run-detail-issue-triage-post-failure#102"}, - input_metadata: %{ - "issue_reference" => %{ - "required" => true, - "source" => "github_webhook" - } - }, - initiating_actor: %{id: "github_webhook", email: nil}, - current_step: "post_github_comment", - started_at: ~U[2026-02-15 06:30:00Z], - step_results: %{ - "run_issue_triage" => %{"classification" => "bug"}, - "run_issue_research" => %{"summary" => "Research summary for failed posting run."}, - "compose_issue_response" => %{ - "proposed_response" => "Thanks for the report. We attempted to post this response." - }, - "post_issue_response" => %{ - "status" => "failed", - "provider" => "github", - "posted" => false, - "approval_mode" => "approval_required", - "approval_decision" => "approved", - "attempted_at" => "2026-02-15T06:31:00Z", - "typed_failure" => %{ - "error_type" => "github_issue_comment_authentication_failed", - "reason_type" => "auth_error", - "detail" => "Bad credentials for GitHub issue comment post.", - "remediation" => "Rotate posting token and retry." - } - } - } - }) - - {:ok, run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: run_id - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "post_github_comment", - transitioned_at: ~U[2026-02-15 06:30:30Z] - }) - - {:ok, _failed_run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "post_github_comment", - transitioned_at: ~U[2026-02-15 06:31:00Z], - transition_metadata: %{ - "typed_failure" => %{ - "error_type" => "github_issue_comment_authentication_failed", - "reason_type" => "auth_error", - "detail" => "Bad credentials for GitHub issue comment post.", - "remediation" => "Rotate posting token and retry.", - "failed_step" => "post_github_comment", - "last_successful_step" => "compose_issue_response" - } - } - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-issue-response-post-status", "failed") - assert has_element?(view, "#run-detail-issue-response-post-error") - - assert has_element?( - view, - "#run-detail-issue-response-post-error-type", - "github_issue_comment_authentication_failed" - ) - - assert has_element?( - view, - "#run-detail-issue-response-post-error-detail", - "Bad credentials for GitHub issue comment post." - ) - - assert has_element?( - view, - "#run-detail-issue-response-post-error-remediation", - "Rotate posting token and retry." - ) - - refute has_element?(view, "#run-detail-issue-response-post-url") - end - - test "renders approval payload context and enables explicit approve action", %{ - conn: _conn - } do - register_owner("approval-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("approval-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-approval", - github_full_name: "owner/repo-run-detail-approval", - default_branch: "main", - settings: %{} - }) - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: "run-detail-approval-#{System.unique_integer([:positive])}", - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render approval payload"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 23:00:00Z], - step_results: %{ - "diff_summary" => "3 files changed (+42/-8).", - "test_summary" => "mix test: 120 passed, 0 failed.", - "risk_notes" => [ - "Touches approval gate orchestration.", - "No credential or secret writes detected." - ] - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 23:01:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 23:02:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run.run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-approval-panel") - assert has_element?(view, "#run-detail-approval-diff-summary", "3 files changed (+42/-8).") - - assert has_element?( - view, - "#run-detail-approval-test-summary", - "mix test: 120 passed, 0 failed." - ) - - assert has_element?( - view, - "#run-detail-approval-risk-note-1", - "Touches approval gate orchestration." - ) - - assert has_element?( - view, - "#run-detail-approval-risk-note-2", - "No credential or secret writes detected." - ) - - assert has_element?(view, "#run-detail-approve-button") - refute has_element?(view, "#run-detail-approve-button[disabled]") - assert has_element?(view, "#run-detail-reject-button") - refute has_element?(view, "#run-detail-reject-button[disabled]") - end - - test "approves awaiting run, resumes execution, and records timeline audit metadata", %{ - conn: _conn - } do - register_owner("approval-resume-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("approval-resume-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-approval-resume", - github_full_name: "owner/repo-run-detail-approval-resume", - default_branch: "main", - settings: %{} - }) - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: "run-detail-approval-resume-#{System.unique_integer([:positive])}", - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Resume on approval"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 23:05:00Z], - step_results: %{ - "diff_summary" => "2 files changed (+9/-1).", - "test_summary" => "mix test: 18 passed, 0 failed.", - "risk_notes" => ["Touches approval resume wiring."] - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 23:06:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 23:07:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run.run_id}", - on_error: :warn - ) - - render_click(element(view, "#run-detail-approve-button")) - - {:ok, persisted_run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: run.run_id - }) - - timeline_index = length(persisted_run.status_transitions) - - assert has_element?(view, "#run-detail-status", "running") - refute has_element?(view, "#run-detail-approval-panel") - assert has_element?(view, "#run-detail-timeline-transition-#{timeline_index}", "running") - assert has_element?(view, "#run-detail-timeline-step-#{timeline_index}", "resume_execution") - - assert has_element?( - view, - "#run-detail-timeline-approval-audit-#{timeline_index}", - "approval-resume-owner@example.com" - ) - - assert persisted_run.status == :running - assert get_in(persisted_run.step_results, ["approval_decision", "decision"]) == "approved" - - assert get_in(persisted_run.step_results, ["approval_decision", "actor", "email"]) == - "approval-resume-owner@example.com" - end - - test "shows typed approval action failure when approval context generation is blocked", %{ - conn: _conn - } do - register_owner("approval-failure-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("approval-failure-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-approval-failure", - github_full_name: "owner/repo-run-detail-approval-failure", - default_branch: "main", - settings: %{} - }) - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: "run-detail-approval-failure-#{System.unique_integer([:positive])}", - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Block approval payload"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 23:10:00Z], - step_results: %{ - "approval_context_generation_error" => "Git diff artifact is missing from prior step output." - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 23:11:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 23:12:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run.run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-status", "awaiting_approval") - - assert has_element?( - view, - "#run-detail-approval-context-missing", - "Approval context is unavailable." - ) - - assert has_element?( - view, - "#run-detail-approval-context-error-message", - "Approval context generation failed" - ) - - assert has_element?( - view, - "#run-detail-approval-context-error-detail", - "Git diff artifact is missing from prior step output." - ) - - assert has_element?( - view, - "#run-detail-approval-context-remediation", - "Publish diff summary, test summary, and risk notes" - ) - - render_click(element(view, "#run-detail-approve-button")) - - assert has_element?(view, "#run-detail-status", "awaiting_approval") - - assert has_element?( - view, - "#run-detail-approval-action-error-type", - "workflow_run_approval_action_failed" - ) - - assert has_element?( - view, - "#run-detail-approval-action-error-detail", - "Approve action is blocked because approval context generation failed." - ) - - assert has_element?( - view, - "#run-detail-approval-action-error-remediation", - "Regenerate diff summary, test summary, and risk notes before retrying approval." - ) - - {:ok, persisted_run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: run.run_id - }) - - assert persisted_run.status == :awaiting_approval - assert has_element?(view, "#run-detail-reject-button") - end - - test "rejects awaiting run in run detail with rationale metadata and cancelled state", %{ - conn: _conn - } do - register_owner("rejection-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("rejection-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-rejection", - github_full_name: "owner/repo-run-detail-rejection", - default_branch: "main", - settings: %{} - }) - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: "run-detail-rejection-#{System.unique_integer([:positive])}", - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Reject run from run detail"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 23:20:00Z], - step_results: %{ - "diff_summary" => "5 files changed (+64/-20).", - "test_summary" => "mix test: 77 passed, 0 failed.", - "risk_notes" => ["Requires a second pass before shipping."] - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 23:21:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 23:22:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run.run_id}", - on_error: :warn - ) - - render_submit(element(view, "#run-detail-reject-form"), %{ - "rationale" => "Needs clearer test coverage before merge." - }) - - {:ok, persisted_run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: run.run_id - }) - - timeline_index = length(persisted_run.status_transitions) - - assert has_element?(view, "#run-detail-status", "cancelled") - refute has_element?(view, "#run-detail-approval-panel") - assert has_element?(view, "#run-detail-timeline-transition-#{timeline_index}", "cancelled") - assert has_element?(view, "#run-detail-timeline-step-#{timeline_index}", "approval_gate") - - assert has_element?( - view, - "#run-detail-timeline-approval-audit-#{timeline_index}", - "rationale=Needs clearer test coverage before merge." - ) - - assert persisted_run.status == :cancelled - assert get_in(persisted_run.step_results, ["approval_decision", "decision"]) == "rejected" - - assert get_in(persisted_run.step_results, ["approval_decision", "rationale"]) == - "Needs clearer test coverage before merge." - end - - test "shows typed rejection retry guidance when rejection policy routing is invalid", %{ - conn: _conn - } do - register_owner("rejection-failure-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("rejection-failure-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-rejection-failure", - github_full_name: "owner/repo-run-detail-rejection-failure", - default_branch: "main", - settings: %{} - }) - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: "run-detail-rejection-failure-#{System.unique_integer([:positive])}", - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{ - source: "workflows", - mode: "manual", - approval_policy: %{ - on_reject: %{ - action: "retry_route", - retry_step: " " - } - } - }, - inputs: %{"task_summary" => "Reject run with invalid policy"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-14 23:30:00Z], - step_results: %{ - "diff_summary" => "1 file changed (+2/-1).", - "test_summary" => "mix test: 12 passed, 0 failed.", - "risk_notes" => ["Rejection policy validation test."] - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-14 23:31:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :awaiting_approval, - current_step: "approval_gate", - transitioned_at: ~U[2026-02-14 23:32:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{run.run_id}", - on_error: :warn - ) - - render_submit(element(view, "#run-detail-reject-form"), %{ - "rationale" => "Routing policy is invalid." - }) - - assert has_element?(view, "#run-detail-status", "awaiting_approval") - - assert has_element?( - view, - "#run-detail-approval-action-error-type", - "workflow_run_approval_action_failed" - ) - - assert has_element?( - view, - "#run-detail-approval-action-error-detail", - "retry route" - ) - - assert has_element?( - view, - "#run-detail-approval-action-error-remediation", - "retry rejection" - ) - - {:ok, persisted_run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: run.run_id - }) - - assert persisted_run.status == :awaiting_approval - assert persisted_run.current_step == "approval_gate" - end - - test "renders standardized failure context with remediation hints for failed runs", %{ - conn: _conn - } do - register_owner("failure-context-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("failure-context-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-failure-context", - github_full_name: "owner/repo-run-detail-failure-context", - default_branch: "main", - settings: %{} - }) - - failed_run_id = "run-detail-failure-context-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: failed_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render typed failure context"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 04:40:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "plan_changes", - transitioned_at: ~U[2026-02-15 04:41:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 04:42:00Z], - transition_metadata: %{ - "failure_context" => %{ - "error_type" => "workflow_step_failed", - "reason_type" => "verification_failed", - "detail" => "Verification failed while running test suite.", - "remediation" => "Inspect failing tests, patch, and retry from run detail.", - "last_successful_step" => "plan_changes" - } - } - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{failed_run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-status", "failed") - assert has_element?(view, "#run-detail-failure-context") - assert has_element?(view, "#run-detail-failure-error-type", "workflow_step_failed") - assert has_element?(view, "#run-detail-failure-reason-type", "verification_failed") - assert has_element?(view, "#run-detail-failure-last-successful-step", "plan_changes") - assert has_element?(view, "#run-detail-failure-failed-step", "run_tests") - assert has_element?(view, "#run-detail-failure-remediation", "retry from run detail") - refute has_element?(view, "#run-detail-failure-missing-fields") - end - - test "renders missing failure context fields when only minimal typed reason is available", %{ - conn: _conn - } do - register_owner("failure-context-minimal-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("failure-context-minimal-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-failure-context-minimal", - github_full_name: "owner/repo-run-detail-failure-context-minimal", - default_branch: "main", - settings: %{} - }) - - failed_run_id = "run-detail-failure-context-minimal-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: failed_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Render missing failure fields"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 04:50:00Z] - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 04:51:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 04:52:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{failed_run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-status", "failed") - assert has_element?(view, "#run-detail-failure-error-type", "workflow_run_failed") - assert has_element?(view, "#run-detail-failure-reason-type", "workflow_run_failed") - assert has_element?(view, "#run-detail-failure-last-successful-step", "unknown") - assert has_element?(view, "#run-detail-failure-remediation", "retry from run detail") - assert has_element?(view, "#run-detail-failure-missing-fields", "error_type") - assert has_element?(view, "#run-detail-failure-missing-fields", "remediation") - assert has_element?(view, "#run-detail-failure-missing-fields", "last_successful_step") - end - - test "retries a failed run from run detail and preserves prior failure lineage on the new attempt", - %{ - conn: _conn - } do - register_owner("retry-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("retry-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-retry", - github_full_name: "owner/repo-run-detail-retry", - default_branch: "main", - settings: %{} - }) - - failed_run_id = "run-detail-retry-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: failed_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Retry failed run from detail"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 05:00:00Z], - step_results: %{ - "failure_report" => %{"step" => "run_tests", "summary" => "2 tests failed."}, - "diff_summary" => "4 files changed (+50/-7)." - }, - error: %{ - "error_type" => "workflow_step_failed", - "reason_type" => "verification_failed", - "detail" => "Verification failed while running test suite." - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:01:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:02:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{failed_run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-status", "failed") - assert has_element?(view, "#run-detail-retry-button") - - render_click(element(view, "#run-detail-retry-button")) - - retry_run_id = "#{failed_run_id}-retry-2" - - retry_path = - "/repos/#{project.id}/runs/#{retry_run_id}?return_to=#{URI.encode_www_form("/repos/#{project.id}")}" - - assert_redirect(view, retry_path) - - {:ok, retried_run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: retry_run_id - }) - - assert retried_run.retry_of_run_id == failed_run_id - assert retried_run.retry_attempt == 2 - assert [%{"run_id" => ^failed_run_id}] = retried_run.retry_lineage - - {:ok, retry_view, _html} = live(recycle(authed_conn), retry_path, on_error: :warn) - - assert has_element?(retry_view, "#run-detail-retry-parent-run", failed_run_id) - assert has_element?(retry_view, "#run-detail-retry-lineage-run-id-1", failed_run_id) - - assert has_element?( - retry_view, - "#run-detail-retry-lineage-reason-type-1", - "verification_failed" - ) - - assert has_element?(retry_view, "#run-detail-retry-lineage-artifact-count-1", "2") - end - - test "blocks retry from run detail with typed policy violation details when retry policy disallows full-run", - %{ - conn: _conn - } do - register_owner("retry-policy-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("retry-policy-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-retry-policy-blocked", - github_full_name: "owner/repo-run-detail-retry-policy-blocked", - default_branch: "main", - settings: %{} - }) - - blocked_run_id = "run-detail-retry-policy-blocked-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: blocked_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{ - source: "workflows", - mode: "manual", - retry_policy: %{full_run: false, mode: "step_only"} - }, - inputs: %{"task_summary" => "Retry blocked by policy"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 05:10:00Z], - step_results: %{ - "failure_report" => %{"step" => "run_tests", "summary" => "1 test failed."} - }, - error: %{ - "error_type" => "workflow_step_failed", - "reason_type" => "verification_failed", - "detail" => "Verification failed while running test suite." - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:11:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:12:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{blocked_run_id}", - on_error: :warn - ) - - render_click(element(view, "#run-detail-retry-button")) - - assert has_element?(view, "#run-detail-status", "failed") - - assert has_element?( - view, - "#run-detail-retry-action-error-type", - "workflow_run_retry_action_failed" - ) - - assert has_element?(view, "#run-detail-retry-action-error-detail", "disallowed") - assert has_element?(view, "#run-detail-retry-action-error-remediation", "retry policy") - - {:ok, no_retry_runs} = - WorkflowRun.read( - query: [ - filter: [project_id: project.id, run_id: "#{blocked_run_id}-retry-2"] - ] - ) - - assert no_retry_runs == [] - end - - test "shows step-level retry control only for workflows that declare step retry and preserves lineage on retry", - %{conn: _conn} do - register_owner("step-retry-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("step-retry-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-step-retry", - github_full_name: "owner/repo-run-detail-step-retry", - default_branch: "main", - settings: %{} - }) - - failed_run_id = "run-detail-step-retry-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: failed_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{ - source: "workflows", - mode: "manual", - retry_policy: %{full_run: false, mode: "step_only", retry_step: "run_tests"} - }, - inputs: %{"task_summary" => "Retry from contract step"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 05:20:00Z], - step_results: %{ - "failure_report" => %{"step" => "run_tests", "summary" => "2 tests failed."} - }, - error: %{ - "error_type" => "workflow_step_failed", - "reason_type" => "verification_failed", - "detail" => "Verification failed while running test suite." - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:21:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:22:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{failed_run_id}", - on_error: :warn - ) - - assert has_element?(view, "#run-detail-step-retry-button") - assert has_element?(view, "#run-detail-step-retry-note", "run_tests") - refute has_element?(view, "#run-detail-step-retry-guidance") - - render_click(element(view, "#run-detail-step-retry-button")) - - retry_run_id = "#{failed_run_id}-retry-2" - - retry_path = - "/repos/#{project.id}/runs/#{retry_run_id}?return_to=#{URI.encode_www_form("/repos/#{project.id}")}" - - assert_redirect(view, retry_path) - - {:ok, retried_run} = - WorkflowRun.get_by_project_and_run_id(%{ - project_id: project.id, - run_id: retry_run_id - }) - - assert retried_run.current_step == "run_tests" - assert retried_run.retry_of_run_id == failed_run_id - assert get_in(retried_run.step_results, ["retry_context", "policy"]) == "step_level" - assert get_in(retried_run.step_results, ["retry_context", "retry_step"]) == "run_tests" - - {:ok, retry_view, _html} = live(recycle(authed_conn), retry_path, on_error: :warn) - assert has_element?(retry_view, "#run-detail-retry-parent-run", failed_run_id) - assert has_element?(retry_view, "#run-detail-retry-lineage-run-id-1", failed_run_id) - end - - test "hides step-level retry control and shows guidance when workflow contract does not declare step retry", - %{conn: _conn} do - register_owner("step-retry-guidance-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("step-retry-guidance-owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-detail-step-retry-guidance", - github_full_name: "owner/repo-run-detail-step-retry-guidance", - default_branch: "main", - settings: %{} - }) - - failed_run_id = "run-detail-step-retry-guidance-#{System.unique_integer([:positive])}" - - {:ok, run} = - WorkflowRun.create(%{ - project_id: project.id, - run_id: failed_run_id, - workflow_name: "implement_task", - workflow_version: 2, - trigger: %{source: "workflows", mode: "manual"}, - inputs: %{"task_summary" => "Step-level retry guidance"}, - input_metadata: %{"task_summary" => %{required: true, source: "manual_workflows_ui"}}, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "queued", - started_at: ~U[2026-02-15 05:30:00Z], - step_results: %{ - "failure_report" => %{"step" => "run_tests", "summary" => "1 test failed."} - }, - error: %{ - "error_type" => "workflow_step_failed", - "reason_type" => "verification_failed", - "detail" => "Verification failed while running test suite." - } - }) - - {:ok, run} = - WorkflowRun.transition_status(run, %{ - to_status: :running, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:31:00Z] - }) - - {:ok, _run} = - WorkflowRun.transition_status(run, %{ - to_status: :failed, - current_step: "run_tests", - transitioned_at: ~U[2026-02-15 05:32:00Z] - }) - - {:ok, view, _html} = - live( - recycle(authed_conn), - ~p"/repos/#{project.id}/runs/#{failed_run_id}", - on_error: :warn - ) - - refute has_element?(view, "#run-detail-step-retry-button") - assert has_element?(view, "#run-detail-step-retry-guidance-detail", "does not declare") - assert has_element?(view, "#run-detail-step-retry-guidance-remediation", "step-level retry") - end - - defp create_memory_workspace_path!(suffix) do - workspace_path = - System.tmp_dir!() - |> Path.join("jido_code_#{suffix}_#{System.unique_integer([:positive])}") - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule RunDetailMemory.MixProject do - use Mix.Project - - def project do - [app: :run_detail_memory, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - File.write!( - Path.join(workspace_path, "lib/example_run_detail_memory.ex"), - """ - defmodule ExampleRunDetailMemory do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - workspace_path - end - - defp seed_run_memory_context!(managed_repo_id, workspace_path, revision, run_id, evidence_id, decision_id, opts) do - work_item_id = Keyword.get(opts, :work_item_id, "work-memory") - link_known_issue_to_decision? = Keyword.get(opts, :link_known_issue_to_decision?, true) - - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph( - managed_repo_id, - workspace_path, - revision: revision - ) - - session_id = "run-memory-context-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:run-detail-memory", - workflow: :review, - work_item_id: work_item_id, - goal: "Seed run detail memory context" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _review_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.review( - session_id: session_id, - actor_id: "system:run-detail-memory", - workflow: :review, - work_item_id: work_item_id, - content: "Review artifact captured for governed run memory context.", - anchors: %{module_name: "ExampleRunDetailMemory"}, - governed_context: %{run_id: run_id, work_item_id: work_item_id, decision_id: decision_id} - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _conversation_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.conversation_turn( - session_id: session_id, - actor_id: "system:run-detail-memory", - workflow: :review, - work_item_id: work_item_id, - content: "Which governed thread should I reopen before promoting follow-up?", - revision: revision, - anchors: %{module_name: "ExampleRunDetailMemory"}, - governed_context: %{ - run_id: run_id, - work_item_id: work_item_id, - evidence_id: evidence_id, - decision_id: decision_id - }, - conversation_id: "run-conversation-32", - turn_id: "run-turn-32", - command_id: "run-command-32", - conversation_event: "clarification_requested", - clarification_state: "awaiting_input", - scope: "work_item_scoped", - attachment_mode: "synthesized_work_item", - status: "awaiting_input", - source: "run_detail" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.known_issue( - session_id: session_id, - actor_id: "system:run-detail-memory", - workflow: :review, - work_item_id: work_item_id, - content: "Run detail should surface memory context for governed review.", - revision: revision, - anchors: %{module_name: "ExampleRunDetailMemory"}, - governed_context: - %{ - run_id: run_id, - work_item_id: work_item_id, - evidence_id: evidence_id - } - |> maybe_put(:decision_id, link_known_issue_to_decision? && decision_id), - classification: %{ - source: "run_detail_live_test", - reason: "Phase 33.1 requires bounded run memory context." - } - ), - revision: revision - ) - - if Keyword.get(opts, :include_decision_memory?, false) do - assert {:ok, _decision_memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.decision( - session_id: session_id, - actor_id: "system:run-detail-memory", - workflow: :review, - work_item_id: work_item_id, - content: "Earlier governed decisions should remain reviewable in durable memory.", - rationale: "Operator supersession coverage needs a prior durable decision memory.", - decision_status: :accepted, - revision: revision, - anchors: %{module_name: "ExampleRunDetailMemory"}, - governed_context: %{run_id: run_id, work_item_id: work_item_id, decision_id: decision_id}, - classification: %{ - source: "run_detail_live_test", - reason: "Phase 33.2 requires a durable decision memory for supersession coverage." - } - ), - revision: revision - ) - end - end - - defp assert_eventually(assertion_fun, attempts \\ 20) - - defp assert_eventually(assertion_fun, attempts) when attempts > 0 do - if assertion_fun.() do - :ok - else - receive do - after - 25 -> - assert_eventually(assertion_fun, attempts - 1) - end - end - end - - defp assert_eventually(_assertion_fun, 0) do - flunk("expected condition to become true") - end - - defp work_item_fixture!(managed_repo, actor_id) do - {:ok, %{work_item: work_item}} = - Ingress.record_operator_intake(%{ - managed_repo_id: managed_repo.id, - channel: "workbench", - intent: "fix_workflow_kickoff", - actor: %{id: actor_id, email: "#{actor_id}@example.com"}, - payload: %{ - "workflow_name" => "fix_failing_tests_#{actor_id}", - "context_item" => %{"type" => "issue", "id" => actor_id} - }, - source_metadata: %{ - "trigger" => %{"source" => "workbench", "mode" => "manual"} - } - }) - - work_item - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, _key, false), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) -end diff --git a/test/jido_code_web/live/security_settings_live_test.exs b/test/jido_code_web/live/security_settings_live_test.exs deleted file mode 100644 index 7c16c888..00000000 --- a/test/jido_code_web/live/security_settings_live_test.exs +++ /dev/null @@ -1,690 +0,0 @@ -defmodule JidoCodeWeb.SecuritySettingsLiveTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.settings_routes_keep_repo_import_liveview_owned - # covers: architecture.policy_layers.operator_surfaces_propagate_current_actor_for_repo_mutations - # covers: architecture.factory_control_plane.settings_github_add_uses_canonical_repo_import - use JidoCodeWeb.ConnCase, async: false - - require Ash.Query - - import Phoenix.LiveViewTest - - alias AshAuthentication.{Info, Jwt, Strategy} - alias AshAuthentication.TokenResource.Actions - alias JidoCode.Accounts - alias JidoCode.Accounts.ApiKey - alias JidoCode.Accounts.Token - alias JidoCode.Accounts.User - alias JidoCode.Control.RepoBridge - alias JidoCode.GitHub.Repo - alias JidoCode.Security.SecretRefs - - test "security tab exposes expiry metadata and revocation controls that invalidate bearer and api key auth", - %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, session_token, owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - %{api_key: api_key, api_key_record: api_key_record} = issue_api_key(owner) - - {:ok, %{"jti" => session_jti}} = Jwt.peek(session_token) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - assert has_element?(view, "#settings-security-token-expires-at-#{session_jti}") - assert has_element?(view, "#settings-security-api-key-expires-at-#{api_key_record.id}") - - view - |> element("#settings-security-revoke-token-#{session_jti}") - |> render_click() - - assert has_element?(view, "#settings-security-token-status-#{session_jti}", "Revoked") - refute has_element?(view, "#settings-security-token-revoked-at-#{session_jti}", "Not revoked") - - refute Actions.valid_jti?(Token, session_jti) - - view - |> element("#settings-security-revoke-api-key-#{api_key_record.id}") - |> render_click() - - assert has_element?(view, "#settings-security-api-key-status-#{api_key_record.id}", "Revoked") - - refute has_element?( - view, - "#settings-security-api-key-revoked-at-#{api_key_record.id}", - "Not revoked" - ) - - api_key_strategy = Info.strategy!(User, :api_key) - - assert {:error, _reason} = - Strategy.action( - api_key_strategy, - :sign_in, - %{"api_key" => api_key} - ) - - assert has_element?(view, "#settings-security-audit-log", "revoked at") - end - - test "failed revocation keeps state unchanged and returns typed recovery instructions", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - %{api_key: api_key, api_key_record: api_key_record} = issue_api_key(owner) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - view - |> element("#settings-security-revoke-api-key-#{api_key_record.id}") - |> render_click() - - revoked_api_key = read_api_key!(api_key_record.id) - assert %DateTime{} = revoked_api_key.revoked_at - - view - |> element("#settings-security-revoke-api-key-#{api_key_record.id}") - |> render_click() - - assert has_element?( - view, - "#settings-security-revocation-error-type", - "api_key_already_revoked" - ) - - assert has_element?(view, "#settings-security-revocation-recovery", "already revoked") - - unchanged_api_key = read_api_key!(api_key_record.id) - assert DateTime.compare(unchanged_api_key.revoked_at, revoked_api_key.revoked_at) == :eq - - api_key_strategy = Info.strategy!(User, :api_key) - - assert {:error, _reason} = - Strategy.action( - api_key_strategy, - :sign_in, - %{"api_key" => api_key} - ) - end - - test "security tab persists encrypted SecretRef metadata and never renders plaintext values", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - secret_name = "github/webhook_secret_#{System.unique_integer([:positive])}" - plaintext_value = "very-secret-value-#{System.unique_integer([:positive])}" - - view - |> form("#settings-security-secret-form", %{ - "security_secret" => %{ - "scope" => "integration", - "name" => secret_name, - "value" => plaintext_value - } - }) - |> render_submit() - - assert has_element?(view, "#settings-security-secret-metadata", secret_name) - assert has_element?(view, "#settings-security-secret-metadata", "integration") - refute has_element?(view, "#settings-security-secret-metadata", plaintext_value) - refute has_element?(view, "#settings-security-secret-value[value='#{plaintext_value}']") - - assert {:ok, metadata_rows} = SecretRefs.list_secret_metadata() - secret_id = metadata_rows |> Enum.find(&(&1.name == secret_name)) |> Map.fetch!(:id) - - assert has_element?(view, "#settings-security-secret-key-version-#{secret_id}", "1") - assert has_element?(view, "#settings-security-secret-rotated-at-#{secret_id}") - assert has_element?(view, "#settings-security-secret-audit-log", "CREATE") - assert has_element?(view, "#settings-security-secret-audit-log", "outcome=succeeded") - assert has_element?(view, "#settings-security-secret-audit-log", "owner@example.com") - - rotated_plaintext_value = "rotated-secret-value-#{System.unique_integer([:positive])}" - - view - |> form("#settings-security-secret-form", %{ - "security_secret" => %{ - "scope" => "integration", - "name" => secret_name, - "value" => rotated_plaintext_value - } - }) - |> render_submit() - - assert has_element?(view, "#settings-security-secret-key-version-#{secret_id}", "2") - assert has_element?(view, "#settings-security-secret-source-value-#{secret_id}", "rotation") - assert has_element?(view, "#settings-security-secret-audit-log", "ROTATE") - refute has_element?(view, "#settings-security-secret-metadata", rotated_plaintext_value) - - view - |> element("#settings-security-secret-revoke-#{secret_id}") - |> render_click() - - refute has_element?(view, "#settings-security-secret-metadata", secret_name) - assert has_element?(view, "#settings-security-secret-audit-log", "REVOKE") - end - - test "security tab rotates provider credentials with before and after verification status", %{ - conn: _conn - } do - original_validator = - Application.get_env(:jido_code, :provider_credential_rotation_validator, :__missing__) - - on_exit(fn -> - restore_env(:provider_credential_rotation_validator, original_validator) - end) - - Application.put_env(:jido_code, :provider_credential_rotation_validator, fn - %{stage: :before} -> - {:ok, "Pre-rotation validation passed."} - - %{stage: :after} -> - {:ok, "Post-rotation validation passed."} - end) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - provider_secret_name = SecretRefs.provider_secret_ref_name(:anthropic) - - assert {:ok, _metadata} = - SecretRefs.persist_operational_secret(%{ - scope: :integration, - name: provider_secret_name, - value: "sk-ant-initial-#{System.unique_integer([:positive])}", - source: :onboarding - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - view - |> form("#settings-security-provider-rotation-form", %{ - "security_provider_rotation" => %{ - "provider" => "anthropic", - "value" => "sk-ant-rotated-#{System.unique_integer([:positive])}" - } - }) - |> render_submit() - - assert has_element?(view, "#settings-security-provider-rotation-before-status", "Passed") - assert has_element?(view, "#settings-security-provider-rotation-after-status", "Passed") - assert has_element?(view, "#settings-security-provider-rotation-rollback-status", "No") - assert has_element?(view, "#settings-security-provider-rotation-continuity-alarm", "None") - - assert {:ok, provider_context} = SecretRefs.provider_credential_context(:anthropic) - assert provider_context.key_version == 2 - end - - test "security tab rolls provider credential references back when post-rotation validation fails", %{ - conn: _conn - } do - original_validator = - Application.get_env(:jido_code, :provider_credential_rotation_validator, :__missing__) - - on_exit(fn -> - restore_env(:provider_credential_rotation_validator, original_validator) - end) - - Application.put_env(:jido_code, :provider_credential_rotation_validator, fn - %{stage: :before} -> - {:ok, "Pre-rotation validation passed."} - - %{stage: :after} -> - {:error, :provider_unreachable} - end) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - provider_secret_name = SecretRefs.provider_secret_ref_name(:openai) - - assert {:ok, _metadata} = - SecretRefs.persist_operational_secret(%{ - scope: :integration, - name: provider_secret_name, - value: "sk-initial-#{System.unique_integer([:positive])}", - source: :onboarding - }) - - assert {:ok, context_before_rotation} = SecretRefs.provider_credential_context(:openai) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - view - |> form("#settings-security-provider-rotation-form", %{ - "security_provider_rotation" => %{ - "provider" => "openai", - "value" => "sk-rotated-#{System.unique_integer([:positive])}" - } - }) - |> render_submit() - - assert has_element?( - view, - "#settings-security-provider-rotation-error-type", - "provider_rotation_validation_failed" - ) - - assert has_element?(view, "#settings-security-provider-rotation-before-status", "Passed") - assert has_element?(view, "#settings-security-provider-rotation-after-status", "Failed") - assert has_element?(view, "#settings-security-provider-rotation-rollback-status", "Yes") - assert has_element?(view, "#settings-security-provider-rotation-continuity-alarm", "None") - - assert {:ok, context_after_rotation} = SecretRefs.provider_credential_context(:openai) - assert context_after_rotation.key_version == context_before_rotation.key_version - assert context_after_rotation.ciphertext == context_before_rotation.ciphertext - end - - test "security tab blocks secret persistence with typed remediation when encryption config is missing", - %{conn: _conn} do - original_key = Application.get_env(:jido_code, :secret_ref_encryption_key, :__missing__) - - on_exit(fn -> - restore_env(:secret_ref_encryption_key, original_key) - end) - - Application.delete_env(:jido_code, :secret_ref_encryption_key) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - secret_name = "missing/encryption_#{System.unique_integer([:positive])}" - - view - |> form("#settings-security-secret-form", %{ - "security_secret" => %{ - "scope" => "integration", - "name" => secret_name, - "value" => "plaintext-that-must-not-persist" - } - }) - |> render_submit() - - assert has_element?( - view, - "#settings-security-secret-error-type", - "secret_encryption_unavailable" - ) - - assert has_element?( - view, - "#settings-security-secret-error-recovery", - "JIDO_CODE_SECRET_REF_ENCRYPTION_KEY" - ) - - refute has_element?(view, "#settings-security-secret-metadata", secret_name) - end - - test "security tab treats secret save as failed when lifecycle audit persistence fails", %{conn: _conn} do - original_audit_persister = - Application.get_env(:jido_code, :secret_lifecycle_audit_persister, :__missing__) - - on_exit(fn -> - restore_env(:secret_lifecycle_audit_persister, original_audit_persister) - end) - - Application.put_env(:jido_code, :secret_lifecycle_audit_persister, fn _attributes -> - {:error, :forced_audit_failure} - end) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/security", on_error: :warn) - - secret_name = "audit/failure_#{System.unique_integer([:positive])}" - - view - |> form("#settings-security-secret-form", %{ - "security_secret" => %{ - "scope" => "integration", - "name" => secret_name, - "value" => "must-not-persist-#{System.unique_integer([:positive])}" - } - }) - |> render_submit() - - assert has_element?( - view, - "#settings-security-secret-error-type", - "secret_audit_persistence_failed" - ) - - refute has_element?(view, "#settings-security-secret-metadata", secret_name) - assert has_element?(view, "#settings-security-secret-audit-log", "No secret lifecycle events recorded yet.") - end - - test "github settings render masked placeholders for known secret patterns", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - unique = System.unique_integer([:positive]) - secret = "sk-test-0123456789abcdef" - - {:ok, repo} = - Repo.create(%{ - owner: "owner-#{unique}", - name: "repo-#{unique}", - settings: %{"Authorization: Bearer #{secret}" => true} - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/github", on_error: :warn) - - assert has_element?(view, "#settings-github-repo-settings-#{repo.id}", "[REDACTED") - refute has_element?(view, "#settings-github-repo-settings-#{repo.id}", secret) - refute has_element?(view, "#settings-github-repo-security-alert-#{repo.id}") - end - - test "github settings suppress unsafe post-render values and raise security alert", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - unique = System.unique_integer([:positive]) - leaked_token = "xoxb-12345678901234567890" - - {:ok, repo} = - Repo.create(%{ - owner: "owner-alert-#{unique}", - name: "repo-alert-#{unique}", - settings: %{"#{leaked_token}" => "enabled"} - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/github", on_error: :warn) - - assert has_element?(view, "#settings-github-repo-settings-#{repo.id}", "[SENSITIVE CONTENT SUPPRESSED]") - assert has_element?(view, "#settings-github-repo-security-alert-#{repo.id}", "Security alert") - refute has_element?(view, "#settings-github-repo-settings-#{repo.id}", leaked_token) - end - - test "settings overview mounts a live vue summary while add-repo workflow stays liveview-owned", - %{conn: _conn} do - register_owner("overview-owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("overview-owner@example.com", "owner-password-123", return_owner: true) - - unique = System.unique_integer([:positive]) - - {:ok, _enabled_repo} = - Repo.create(%{ - owner: "overview-owner-#{unique}", - name: "enabled-repo-#{unique}", - settings: %{} - }) - - {:ok, disabled_repo} = - Repo.create(%{ - owner: "overview-owner-#{unique}", - name: "disabled-repo-#{unique}", - settings: %{} - }) - - {:ok, _disabled_repo} = Repo.disable(disabled_repo) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/github", on_error: :warn) - - vue = assert_vue_component(view, "SettingsOverviewWidget", id: "settings-overview-widget") - - assert vue.props["activeTab"] == "github" - assert vue.props["openAddRepoVisible"] == true - assert vue.props["activeTabSummary"] =~ "2 GitHub repo(s) connected" - assert_vue_handler(view, "openAddRepo", "open_add_modal", id: "settings-overview-widget") - - assert Enum.any?(vue.props["cards"], fn card -> - card["id"] == "github" and card["value"] == "2" and card["active"] == true - end) - - assert has_element?(view, "button[phx-click='open_add_modal']", "Add Repository") - - view - |> element("button[phx-click='open_add_modal']") - |> render_click() - - assert has_element?(view, "#add-repo-modal") - end - - test "github settings add repository imports the canonical managed repo and refreshes the settings list", - %{conn: _conn} do - original_importer = Application.get_env(:jido_code, :setup_project_importer, :__missing__) - - on_exit(fn -> - restore_env(:setup_project_importer, original_importer) - end) - - Application.put_env(:jido_code, :setup_project_importer, fn %{ - checked_at: checked_at, - selected_repository: full_name - } -> - [_owner, name] = String.split(full_name, "/", parts: 2) - - {:ok, %{managed_repo: managed_repo}} = - RepoBridge.upsert_managed_repo(%{ - full_name: full_name, - display_name: name, - default_branch: "main", - workspace_settings: %{ - "workspace_environment" => "sprite", - "workspace_initialized" => true, - "baseline_synced" => true, - "clone_status" => "ready", - "clone_status_history" => [ - %{ - "status" => "ready", - "transitioned_at" => DateTime.to_iso8601(checked_at) - } - ], - "last_synced_at" => DateTime.to_iso8601(checked_at), - "synced_branch" => "main" - } - }) - - settings_import_report(full_name, managed_repo, checked_at) - end) - - {:ok, _owner} = - User.bootstrap_admin( - %{ - email: "settings-import-owner@example.com", - password: "owner-password-123", - password_confirmation: "owner-password-123" - }, - authorize?: false - ) - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn( - "settings-import-owner@example.com", - "owner-password-123", - return_owner: true - ) - - unique = System.unique_integer([:positive]) - full_name = "agentjido/settings-import-#{unique}" - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/github", on_error: :warn) - - view - |> element("button[phx-click='open_add_modal']") - |> render_click() - - view - |> form("#add-repo-modal form", %{ - "form" => %{ - "owner" => "agentjido", - "name" => "settings-import-#{unique}" - } - }) - |> render_submit() - - refute has_element?(view, "#add-repo-modal") - assert has_element?(view, "#repos-list", full_name) - refute has_element?(view, "#settings-github-repo-save-error") - - assert {:ok, %{managed_repo: managed_repo}} = RepoBridge.repo_scope(full_name) - assert managed_repo.display_name == "settings-import-#{unique}" - end - - test "github settings keep the modal open and surface importer remediation when managed import blocks", - %{conn: _conn} do - original_importer = Application.get_env(:jido_code, :setup_project_importer, :__missing__) - - on_exit(fn -> - restore_env(:setup_project_importer, original_importer) - end) - - Application.put_env(:jido_code, :setup_project_importer, fn %{ - checked_at: checked_at, - selected_repository: full_name - } -> - %{ - checked_at: checked_at, - status: :blocked, - selected_repository: full_name, - project_record: nil, - baseline_metadata: nil, - detail: "Selected repository is not in the validated repository access list.", - remediation: "Refresh GitHub access and retry import from Settings.", - error_type: "repository_selection_unavailable" - } - end) - - {:ok, _owner} = - User.bootstrap_admin( - %{ - email: "settings-import-blocked@example.com", - password: "owner-password-123", - password_confirmation: "owner-password-123" - }, - authorize?: false - ) - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn( - "settings-import-blocked@example.com", - "owner-password-123", - return_owner: true - ) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/github", on_error: :warn) - - view - |> element("button[phx-click='open_add_modal']") - |> render_click() - - view - |> form("#add-repo-modal form", %{ - "form" => %{ - "owner" => "blocked-owner", - "name" => "blocked-repo" - } - }) - |> render_submit() - - assert has_element?(view, "#add-repo-modal") - - assert has_element?( - view, - "#settings-github-repo-save-error", - "Selected repository is not in the validated repository access list." - ) - - assert has_element?( - view, - "#settings-github-repo-save-error", - "Refresh GitHub access and retry import from Settings." - ) - end - - defp issue_api_key(owner) do - expires_at = DateTime.add(DateTime.utc_now(), 3600, :second) - - {:ok, api_key_record} = - Ash.create( - ApiKey, - %{user_id: owner.id, expires_at: expires_at}, - domain: Accounts, - authorize?: false - ) - - api_key = - api_key_record - |> Map.get(:__metadata__, %{}) - |> Map.fetch!(:plaintext_api_key) - - %{api_key: api_key, api_key_record: api_key_record} - end - - defp read_api_key!(api_key_id) do - query = - ApiKey - |> Ash.Query.filter(id == ^api_key_id) - |> Ash.Query.limit(1) - - {:ok, [api_key]} = Ash.read(query, domain: Accounts, authorize?: false) - - api_key - end - - defp settings_import_report(full_name, managed_repo, checked_at) do - %{ - checked_at: checked_at, - status: :ready, - selected_repository: full_name, - project_record: %{ - id: managed_repo.id, - name: managed_repo.display_name, - source_kind: :github, - source_identifier: full_name, - github_full_name: full_name, - local_path: nil, - default_branch: "main", - import_mode: :created, - imported_at: checked_at, - clone_status: :ready, - clone_status_history: [%{status: :ready, transitioned_at: checked_at}], - last_synced_at: checked_at - }, - baseline_metadata: %{ - workspace_initialized: true, - baseline_synced: true, - default_workflow_registered: true, - agent_configuration_registered: true, - status: :ready, - initialized_at: checked_at, - synced_branch: "main", - last_synced_at: checked_at, - workspace_environment: :sprite, - workspace_path: nil - }, - detail: "Repository import is complete. Workspace clone is ready and baseline synced to `main`.", - remediation: "Project import is ready.", - error_type: nil - } - end -end diff --git a/test/jido_code_web/live/settings_operator_auth_live_test.exs b/test/jido_code_web/live/settings_operator_auth_live_test.exs deleted file mode 100644 index 0f8239bd..00000000 --- a/test/jido_code_web/live/settings_operator_auth_live_test.exs +++ /dev/null @@ -1,196 +0,0 @@ -defmodule JidoCodeWeb.SettingsOperatorAuthLiveTest do - # covers: auth.operator_settings.sections_separated - # covers: auth.operator_settings.broker_trust_configuration_ui - # covers: auth.operator_settings.github_service_validation_feedback - # covers: auth.operator_settings.integration_boundary_visible - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AuthProviders.ProviderConfig - - @checker_env :setup_github_credential_checker - @checked_at ~U[2026-03-15 18:30:00Z] - - setup do - original_checker = Application.get_env(:jido_code, @checker_env, :__missing__) - - on_exit(fn -> - case original_checker do - :__missing__ -> Application.delete_env(:jido_code, @checker_env) - value -> Application.put_env(:jido_code, @checker_env, value) - end - end) - - :ok - end - - test "authenticated operator can open the settings auth tab as a durable auth-and-integrations destination", - %{conn: _conn} do - Application.put_env(:jido_code, @checker_env, fn _context -> - blocked_github_report() - end) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/auth") - - assert has_element?(view, "#settings-nav-auth", "Auth & Integrations") - assert has_element?(view, "#settings-auth-provider-login-settings", "Provider Login") - assert has_element?(view, "#settings-auth-git-provider-integrations", "Git Provider Integrations") - assert has_element?(view, "#settings-auth-provider-login-card-github", "GitHub") - assert has_element?(view, "#settings-auth-provider-login-card-gitlab", "GitLab") - assert has_element?(view, "#settings-auth-provider-login-card-bitbucket", "Bitbucket") - assert has_element?(view, "#settings-auth-github-service-status", "GitHub automation is blocked.") - assert has_element?(view, "#settings-auth-github-service-secret-refs", "vcs/github/app_id") - assert has_element?(view, "#settings-auth-github-service-secret-refs", "vcs/github/pat") - assert has_element?(view, "#settings-auth-git-provider-integrations", "named placeholder adapter") - end - - test "authenticated operator can persist broker trust and allowlist settings from settings auth tab", - %{conn: _conn} do - Application.put_env(:jido_code, @checker_env, fn _context -> - ready_github_report() - end) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/auth") - - view - |> form("#settings-auth-provider-login-form-github", %{ - "provider_login" => %{ - "provider" => "github", - "enabled" => "true", - "login_enabled" => "true", - "allowlist_mode" => "organizations", - "allowlist_values" => "agentjido\njido-labs", - "broker_base_url" => "https://broker.example.com", - "broker_issuer" => "https://broker.example.com", - "broker_audience" => "jido-code" - } - }) - |> render_submit() - - assert has_element?(view, "#settings-auth-provider-login-card-github", "Ready") - - {:ok, config} = - ProviderConfig.get_by_provider_host(:github, "github.com", authorize?: false) - - assert config.enabled == true - assert config.login_enabled == true - assert config.allowlist_mode == :organizations - assert config.allowlist_values == ["agentjido", "jido-labs"] - assert config.broker_base_url == "https://broker.example.com" - assert config.broker_issuer == "https://broker.example.com" - assert config.broker_audience == "jido-code" - end - - test "authenticated operator can refresh github integration validation feedback from settings auth tab", - %{conn: _conn} do - Application.put_env(:jido_code, @checker_env, fn _context -> - blocked_github_report() - end) - - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token, _owner} = - authenticate_owner_conn("owner@example.com", "owner-password-123", return_owner: true) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/settings/auth") - - assert has_element?( - view, - "#settings-auth-github-service-path-github_app", - "GitHub App credentials are not fully configured." - ) - - Application.put_env(:jido_code, @checker_env, fn _context -> - ready_github_report() - end) - - view - |> element("#settings-auth-refresh-github-service-checks") - |> render_click() - - assert has_element?(view, "#settings-auth-github-service-status", "GitHub automation is ready.") - - assert has_element?( - view, - "#settings-auth-github-service-path-github_app", - "GitHub App credentials are ready." - ) - - assert has_element?( - view, - "#settings-auth-github-service-path-pat", - "PAT fallback confirms repository access." - ) - end - - defp blocked_github_report do - %{ - checked_at: @checked_at, - status: :blocked, - owner_context: "owner@example.com", - paths: [ - %{ - path: :github_app, - name: "GitHub App", - status: :not_configured, - detail: "GitHub App credentials are not fully configured.", - remediation: "Set GitHub App credentials and retry validation.", - repository_access: :unconfirmed, - checked_at: @checked_at - }, - %{ - path: :pat, - name: "Personal Access Token (PAT)", - status: :invalid, - detail: "PAT fallback is unavailable until a deployment-local token is configured.", - remediation: "Set a valid GitHub PAT or prefer a GitHub App installation token.", - repository_access: :unconfirmed, - checked_at: @checked_at - } - ] - } - end - - defp ready_github_report do - %{ - checked_at: @checked_at, - status: :ready, - owner_context: "owner@example.com", - paths: [ - %{ - path: :github_app, - name: "GitHub App", - status: :ready, - detail: "GitHub App credentials are ready.", - remediation: "GitHub App path is ready.", - repository_access: :confirmed, - repositories: ["agentjido/jido_code"], - validated_at: @checked_at, - checked_at: @checked_at - }, - %{ - path: :pat, - name: "Personal Access Token (PAT)", - status: :ready, - detail: "PAT fallback confirms repository access.", - remediation: "PAT fallback is available.", - repository_access: :confirmed, - repositories: ["agentjido/jido_code"], - validated_at: @checked_at, - checked_at: @checked_at - } - ] - } - end -end diff --git a/test/jido_code_web/live/setup_live_github_auth_mode_test.exs b/test/jido_code_web/live/setup_live_github_auth_mode_test.exs deleted file mode 100644 index b842069b..00000000 --- a/test/jido_code_web/live/setup_live_github_auth_mode_test.exs +++ /dev/null @@ -1,46 +0,0 @@ -defmodule JidoCodeWeb.SetupLiveGitHubAuthModeTest do - # covers: auth.github_integration.non_blocking_local_auth - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - setup do - original_config = Application.get_env(:jido_code, :system_config, :__missing__) - original_target = System.get_env("BURRITO_TARGET") - - on_exit(fn -> - restore_env(:system_config, original_config) - restore_system_env("BURRITO_TARGET", original_target) - end) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - System.delete_env("BURRITO_TARGET") - :ok - end - - test "cloud installs keep GitHub follow-up available without blocking local admin entry", %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - assert has_element?(view, "#setup-complete-continue", "Continue to dashboard") - assert has_element?(view, "#setup-start-choice-github") - assert has_element?(view, "#setup-start-choice-later") - refute has_element?(view, "#setup-start-choice-local_repo") - end -end diff --git a/test/jido_code_web/live/setup_live_test.exs b/test/jido_code_web/live/setup_live_test.exs deleted file mode 100644 index 13419617..00000000 --- a/test/jido_code_web/live/setup_live_test.exs +++ /dev/null @@ -1,1677 +0,0 @@ -defmodule JidoCodeWeb.SetupLiveTest do - # covers: baseline.surface.public_entry_routes - # covers: architecture.frontend_stack.product_owned_mounting_boundary - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.frontend_stack.hybrid_surfaces_fail_safe_when_richer_client_path_degrades - # covers: users.admin_system.bootstrap_admin - # covers: users.admin_system.admin_role_assignment - # covers: users.admin_system.registration_guardrails - # covers: setup.onboarding.post_bootstrap_start_surface - # covers: setup.onboarding.deployment_mode_auto_detected - # covers: setup.onboarding.runtime_environment_selection_distinct_from_install_flavor - # covers: setup.onboarding.runtime_environment_selection_persisted_metadata - # covers: setup.onboarding.explicit_completion_path_to_dashboard - # covers: setup.onboarding.deferred_integrations - # covers: setup.onboarding.github_repository_selection_persisted_metadata - # covers: setup.onboarding.github_repository_selection_supports_account_filter_and_matching_search - # covers: setup.onboarding.github_pat_capture_persisted_secret_ref - # covers: setup.onboarding.github_pat_capture_requires_encryption_ready_secret_storage - # covers: setup.onboarding.start_path_preference_persisted - use JidoCodeWeb.ConnCase, async: false - - alias AshAuthentication.{Info, Strategy} - alias JidoCode.Accounts - alias JidoCode.Accounts.User - alias JidoCode.Security.SecretRefs - alias JidoCode.Repo - - import Phoenix.LiveViewTest - - @checked_at ~U[2026-02-13 12:34:56Z] - @owner_recovery_audit_event [:jido_code, :auth, :owner_recovery, :completed] - - setup do - original_config = Application.get_env(:jido_code, :system_config, :__missing__) - original_prerequisite_checker = Application.get_env(:jido_code, :setup_prerequisite_checker, :__missing__) - original_github_credential_checker = Application.get_env(:jido_code, :setup_github_credential_checker, :__missing__) - original_github_http_client = Application.get_env(:jido_code, :setup_github_http_client, :__missing__) - - original_github_http_client_options = - Application.get_env(:jido_code, :setup_github_http_client_options, :__missing__) - - original_project_importer = Application.get_env(:jido_code, :setup_project_importer, :__missing__) - original_frontend_override = Application.get_env(:jido_code, :frontend_assets_override, :__missing__) - original_target = fetch_system_env("BURRITO_TARGET") - - on_exit(fn -> - restore_env(:system_config, original_config) - restore_env(:setup_prerequisite_checker, original_prerequisite_checker) - restore_env(:setup_github_credential_checker, original_github_credential_checker) - restore_env(:setup_github_http_client, original_github_http_client) - restore_env(:setup_github_http_client_options, original_github_http_client_options) - restore_env(:setup_project_importer, original_project_importer) - restore_env(:frontend_assets_override, original_frontend_override) - restore_system_env("BURRITO_TARGET", original_target) - end) - - Application.delete_env(:jido_code, :setup_prerequisite_checker) - Application.delete_env(:jido_code, :setup_github_credential_checker) - Application.delete_env(:jido_code, :setup_github_http_client) - Application.delete_env(:jido_code, :setup_github_http_client_options) - Application.delete_env(:jido_code, :setup_project_importer) - Application.delete_env(:jido_code, :frontend_assets_override) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 1, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }) - - Application.put_env(:jido_code, :setup_prerequisite_checker, fn _timeout_ms -> - passing_prerequisite_report() - end) - - System.delete_env("BURRITO_TARGET") - reset_owner_state!() - - :ok - end - - test "zero-user bootstrap on /welcome persists prerequisite and admin state, then enters the setup start surface", - %{conn: conn} do - assert {:error, {:live_redirect, %{to: "/welcome"}}} = - live(conn, ~p"/setup", on_error: :warn) - - {:ok, view, _html} = live(conn, ~p"/welcome", on_error: :warn) - - view - |> form("#welcome-owner-form", %{ - "owner" => %{ - "email" => "owner@example.com", - "password" => "owner-password-123", - "password_confirmation" => "owner-password-123" - } - }) - |> render_submit() - - auth_redirect_path = - view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/setup" - - {:ok, setup_view, _html} = live(recycle(auth_response), ~p"/setup", on_error: :warn) - - assert_setup_start_surface(setup_view) - assert runtime_defaults_widget(setup_view).props["selectedStartPathLabel"] == "Not chosen yet" - assert_owner_count(1) - assert_single_owner_admin!(true) - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{ - "validated_note" => "System prerequisites verified (welcome flow).", - "prerequisite_checks" => %{"status" => "pass"} - }, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - } = Application.get_env(:jido_code, :system_config) - end - - test "continue setup sign-in from /welcome enters the setup start surface without advancing onboarding state", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - {:ok, view, _html} = live(conn, ~p"/welcome", on_error: :warn) - - view - |> form("#continue-setup-owner-form", %{ - "owner" => %{ - "email" => "owner@example.com", - "password" => "owner-password-123" - } - }) - |> render_submit() - - auth_redirect_path = - view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/setup" - - {:ok, setup_view, _html} = live(recycle(auth_response), ~p"/setup", on_error: :warn) - - assert_setup_start_surface(setup_view) - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{"validated_note" => "Owner account bootstrapped."} - } - } = Application.get_env(:jido_code, :system_config) - end - - test "continue setup recovery on /welcome requires explicit verification, resets credentials, and keeps the bootstrap admin role", - %{conn: conn} do - attach_owner_recovery_audit_handler() - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 2, - onboarding_state: %{"1" => %{"validated_note" => "Prerequisite checks passed"}} - }) - - {:ok, view, _html} = live(conn, ~p"/welcome", on_error: :warn) - - view - |> form("#continue-setup-recovery-form", %{ - "owner_recovery" => %{ - "email" => "owner@example.com", - "password" => "owner-recovered-password-789", - "password_confirmation" => "owner-recovered-password-789", - "verification_phrase" => "RECOVER OWNER ACCESS", - "verification_ack" => "true" - } - }) - |> render_submit() - - auth_redirect_path = - view - |> assert_redirect() - |> redirect_path() - - auth_response = build_conn() |> get(auth_redirect_path) - assert redirected_to(auth_response, 302) == "/setup" - - {:ok, setup_view, _html} = live(recycle(auth_response), ~p"/setup", on_error: :warn) - assert_setup_start_surface(setup_view) - - strategy = Info.strategy!(User, :password) - - assert {:error, _reason} = - Strategy.action( - strategy, - :sign_in, - %{"email" => "owner@example.com", "password" => "owner-password-123"}, - context: %{token_type: :sign_in} - ) - - assert {:ok, _owner} = - Strategy.action( - strategy, - :sign_in, - %{"email" => "owner@example.com", "password" => "owner-recovered-password-789"}, - context: %{token_type: :sign_in} - ) - - assert_receive {:owner_recovery_audit, event_name, measurements, metadata} - assert event_name == @owner_recovery_audit_event - assert measurements.count == 1 - assert metadata.owner_email == "owner@example.com" - assert metadata.recovery_mode == "bootstrap" - - assert_owner_count(1) - assert_single_owner_admin!(true) - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "recovered", - "registration_actions_disabled" => true, - "validated_note" => "Owner account recovered." - } - } - } = Application.get_env(:jido_code, :system_config) - end - - test "setup redirects unsigned sessions back to /welcome once bootstrap is complete", %{conn: conn} do - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{"2" => %{"owner_email" => "owner@example.com"}} - }) - - assert {:error, {:live_redirect, %{to: "/welcome"}}} = - live(conn, ~p"/setup", on_error: :warn) - end - - test "desktop setup start surface emphasizes adding a local repo first", %{conn: conn} do - System.put_env("BURRITO_TARGET", "darwin-aarch64") - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - runtime_widget = runtime_defaults_widget(view) - start_widget = start_path_widget(view) - - assert has_element?(view, "#setup-complete-continue", "Continue to dashboard") - - assert runtime_widget.props["installFlavor"] == "Desktop" - assert runtime_widget.props["savedRuntimeLabel"] == "Cloud" - - assert runtime_widget.props["runtimeDescription"] == - "Cloud defaults keep new imports on Sprite-backed execution until a managed repository is rebound to its own workspace settings." - - assert_vue_handler(view, "changeRuntimeEnvironment", "change_runtime_environment", - id: "setup-runtime-defaults-widget" - ) - - assert_vue_handler(view, "saveRuntimeEnvironment", "save_runtime_environment", id: "setup-runtime-defaults-widget") - - assert Enum.map(start_widget.props["options"], & &1["id"]) == ["local_repo", "github", "later"] - assert start_option(start_widget, "local_repo")["badgeLabel"] == "Recommended" - assert start_option(start_widget, "local_repo")["buttonLabel"] == "Add local repo" - assert start_option(start_widget, "github")["buttonLabel"] == "Connect GitHub" - assert start_option(start_widget, "later")["buttonLabel"] == "Do this later" - - assert_vue_handler(view, "chooseStartPath", "choose_start_path", id: "setup-start-path-selector") - end - - test "cloud setup start surface keeps GitHub and later as the only start choices", %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - runtime_widget = runtime_defaults_widget(view) - start_widget = start_path_widget(view) - - assert has_element?(view, "#setup-complete-continue", "Continue to dashboard") - - assert runtime_widget.props["installFlavor"] == "Cloud" - assert runtime_widget.props["savedRuntimeLabel"] == "Cloud" - - assert runtime_widget.props["runtimeDescription"] == - "Cloud defaults keep new imports on Sprite-backed execution until a managed repository is rebound to its own workspace settings." - - assert Enum.map(start_widget.props["options"], & &1["id"]) == ["github", "later"] - assert start_option(start_widget, "github")["badgeLabel"] == "Recommended" - assert start_option(start_widget, "github")["buttonLabel"] == "Connect GitHub" - assert start_option(start_widget, "later")["buttonLabel"] == "Do this later" - end - - test "saving a local runtime environment persists validated local defaults without advancing onboarding progress", - %{conn: conn} do - workspace_root = tmp_workspace_path!() - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - }, - default_environment: :sprite, - workspace_root: nil - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - render_change(view, "change_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "local" - } - }) - - assert runtime_defaults_widget(view).props["form"]["mode"] == "local" - - render_submit(view, "save_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "local", - "workspace_root" => workspace_root - } - }) - - runtime_widget = runtime_defaults_widget(view) - - assert runtime_widget.props["runtimeDescription"] =~ - "Local defaults seed new imports from a workspace root on this machine" - - assert runtime_widget.props["savedRuntimeLabel"] == "Local" - - assert runtime_widget.props["savedRuntimeNote"] == - "New local imports will seed repo workspace paths from #{workspace_root} until each managed repository is rebound." - - assert has_element?( - view, - "#setup-complete-panel", - "runtime defaults, and repo-level workspace bindings remain editable after onboarding." - ) - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - default_environment: :local, - workspace_root: ^workspace_root, - onboarding_state: %{ - "3" => %{ - "runtime_environment" => %{ - "mode" => "local", - "default_environment" => "local", - "workspace_root" => ^workspace_root, - "status" => "ready" - }, - "runtime_environment_note" => runtime_note - } - } - } = Application.get_env(:jido_code, :system_config) - - assert runtime_note =~ workspace_root - end - - test "saving cloud runtime defaults after local defaults refreshes the saved runtime summary", %{ - conn: conn - } do - workspace_root = tmp_workspace_path!() - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - }, - default_environment: :local, - workspace_root: workspace_root - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - assert runtime_defaults_widget(view).props["savedRuntimeLabel"] == "Local" - - render_change(view, "change_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "cloud" - } - }) - - assert runtime_defaults_widget(view).props["runtimeDescription"] == - "Cloud defaults keep new imports on Sprite-backed execution until a managed repository is rebound to its own workspace settings." - - render_submit(view, "save_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "cloud" - } - }) - - runtime_widget = runtime_defaults_widget(view) - - assert runtime_widget.props["savedRuntimeLabel"] == "Cloud" - - assert runtime_widget.props["savedRuntimeNote"] == - "Cloud defaults map to Sprite-backed execution and do not bind repository-local workspace paths." - - assert runtime_widget.props["form"]["mode"] == "cloud" - assert runtime_widget.props["form"]["workspaceRoot"] == "" - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - default_environment: :sprite, - workspace_root: nil - } = Application.get_env(:jido_code, :system_config) - end - - test "saving a local runtime environment rejects an invalid workspace root", %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - }, - default_environment: :sprite, - workspace_root: nil - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - render_change(view, "change_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "local" - } - }) - - render_submit(view, "save_runtime_environment", %{ - "runtime_environment" => %{ - "mode" => "local", - "workspace_root" => "relative/workspaces" - } - }) - - assert runtime_defaults_widget(view).props["saveError"] =~ - "Workspace root must be an absolute path." - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - default_environment: :sprite, - workspace_root: nil, - onboarding_state: onboarding_state - } = Application.get_env(:jido_code, :system_config) - - refute get_in(onboarding_state, ["3", "runtime_environment"]) - end - - test "choosing a start path persists the saved choice without advancing onboarding progress", %{ - conn: conn - } do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - assert runtime_defaults_widget(view).props["selectedStartPathLabel"] == "Connect GitHub" - assert start_option(start_path_widget(view), "github")["badgeLabel"] == "Saved" - assert start_option(start_path_widget(view), "github")["buttonLabel"] == "Saved" - assert start_option(start_path_widget(view), "github")["disabled"] == true - - assert %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{"validated_note" => "Owner account bootstrapped."}, - "3" => %{ - "start_path" => "github", - "deployment_mode" => "cloud", - "validated_note" => "GitHub path selected." - } - } - } = Application.get_env(:jido_code, :system_config) - end - - test "choosing GitHub surfaces the bounded LiveVue repository selector with linked repository options", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - }, - "4" => %{ - "github_credentials" => %{ - "paths" => [ - %{ - "path" => "github_app", - "status" => "ready", - "repository_access" => "confirmed", - "repositories" => [ - %{"id" => "repo_100", "full_name" => "owner/repo-one"}, - %{"id" => "repo_200", "full_name" => "owner/repo-two"}, - %{"id" => "repo_300", "full_name" => "agentjido/repo-three"} - ] - } - ] - } - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - refute has_element?(view, "#setup-github-repository-panel") - - choose_start_path(view, "github") - - assert has_element?(view, "#setup-github-repository-panel") - refute has_element?(view, "#setup-github-repository-summary") - - assert_vue_component(view, "SetupGitHubRepositorySelectorWidget", id: "setup-github-repository-selector") - - assert_vue_handler(view, "selectRepository", "select_github_repository", id: "setup-github-repository-selector") - - assert_vue_handler(view, "refreshRepositories", "refresh_github_repository_listing", - id: "setup-github-repository-selector" - ) - - assert_vue_handler(view, "importRepository", "import_selected_github_repository", - id: "setup-github-repository-selector" - ) - - selector = vue(view, id: "setup-github-repository-selector") - - assert selector.props["panelTitle"] == "Choose GitHub repositories" - assert selector.props["panelBadgeLabel"] == "Optional follow-up" - - assert selector.props["panelSummary"] == - "Pick one or more linked GitHub repositories and import them into the control plane." - - assert selector.props["panelDetail"] == - "Pick one or more linked GitHub repositories to import into the control plane now, or finish onboarding and come back later. Each imported managed repository can later keep its own workspace binding." - - assert selector.props["boundaryNote"] == - "LiveView still owns PAT capture, persistence, and completion; this widget only makes repository follow-up easier to scan and resume." - - assert selector.props["selectedRepositories"] == [] - assert selector.props["importSelectedRepositories"] == [] - assert selector.props["listingStatus"] == "ready" - assert selector.props["repositoryCountLabel"] == "3 linked repositories available for import." - assert selector.props["accountOptions"] == ["agentjido", "owner"] - - assert Enum.map(selector.props["repositoryOptions"], & &1["fullName"]) == [ - "agentjido/repo-three", - "owner/repo-one", - "owner/repo-two" - ] - end - - test "GitHub repository selector persists multi-selection toggles through the LiveView event contract", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - }, - "4" => %{ - "github_credentials" => %{ - "paths" => [ - %{ - "path" => "github_app", - "status" => "ready", - "repository_access" => "confirmed", - "repositories" => [ - %{"id" => "repo_100", "full_name" => "owner/repo-one"}, - %{"id" => "repo_200", "full_name" => "owner/repo-two"} - ] - } - ] - } - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - render_click(view, "select_github_repository", %{"repository_full_name" => "owner/repo-one"}) - - assert %{ - onboarding_state: %{ - "7" => %{ - "selected_repository" => "owner/repo-one", - "selected_repositories" => ["owner/repo-one"] - } - } - } = Application.get_env(:jido_code, :system_config) - - assert vue(view, id: "setup-github-repository-selector").props["selectedRepositories"] == [ - "owner/repo-one" - ] - - render_click(view, "select_github_repository", %{"repository_full_name" => "owner/repo-two"}) - - assert %{ - onboarding_state: %{ - "7" => %{ - "selected_repository" => "owner/repo-one", - "selected_repositories" => ["owner/repo-one", "owner/repo-two"] - } - } - } = Application.get_env(:jido_code, :system_config) - - assert vue(view, id: "setup-github-repository-selector").props["selectedRepositories"] == [ - "owner/repo-one", - "owner/repo-two" - ] - - render_click(view, "select_github_repository", %{"repository_full_name" => "owner/repo-one"}) - - assert %{ - onboarding_state: %{ - "7" => %{ - "selected_repository" => "owner/repo-two", - "selected_repositories" => ["owner/repo-two"] - } - } - } = Application.get_env(:jido_code, :system_config) - - assert vue(view, id: "setup-github-repository-selector").props["selectedRepositories"] == [ - "owner/repo-two" - ] - - render_click(view, "select_github_repository", %{"repository_full_name" => "owner/repo-two"}) - - assert %{ - onboarding_state: %{ - "7" => step_state - } - } = Application.get_env(:jido_code, :system_config) - - refute Map.has_key?(step_state, "selected_repository") - refute Map.has_key?(step_state, "selected_repositories") - assert vue(view, id: "setup-github-repository-selector").props["selectedRepositories"] == [] - end - - test "GitHub repository selector does not preselect previously imported repositories on reload", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - }, - "4" => %{ - "github_credentials" => %{ - "paths" => [ - %{ - "path" => "github_app", - "status" => "ready", - "repository_access" => "confirmed", - "repositories" => [ - %{"id" => "repo_100", "full_name" => "owner/repo-one"}, - %{"id" => "repo_200", "full_name" => "owner/repo-two"} - ] - } - ] - } - }, - "7" => %{ - "project_import_batch" => %{ - "checked_at" => DateTime.to_iso8601(@checked_at), - "status" => "ready", - "selected_repositories" => ["owner/repo-one"], - "imported_repositories" => ["owner/repo-one"], - "project_paths" => [ - %{ - "repository_full_name" => "owner/repo-one", - "managed_repo_id" => "managed-repo-repo-one", - "path" => "/repos/managed-repo-repo-one" - } - ], - "detail" => "Imported owner/repo-one into the managed-repository control plane.", - "remediation" => "Import complete.", - "error_type" => nil - } - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - selector = vue(view, id: "setup-github-repository-selector") - - assert selector.props["selectedRepositories"] == [] - assert selector.props["repositoryCountLabel"] == "2 linked repositories available for import." - assert selector.props["importProjectDisplayName"] == "repo-one" - end - - test "choosing GitHub requires PAT capture when deployment-local repository access is not configured", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - assert has_element?(view, "#setup-github-pat-panel") - - assert has_element?( - view, - "#setup-github-repository-summary", - "Save a deployment-local GitHub PAT first, then repository selection will unlock automatically." - ) - - assert has_element?( - view, - "#setup-github-repository-selector-deferred", - "Repository selection unlocks after this install has a saved GitHub PAT and setup refreshes access." - ) - - assert has_element?( - view, - "#setup-github-repository-panel", - "Each managed repository can keep its own workspace binding after import." - ) - - assert has_element?(view, "#setup-github-pat-form") - assert has_element?(view, "#setup-github-repository-selector-deferred") - refute has_element?(view, "#setup-github-repository-selector") - - assert has_element?( - view, - "#setup-github-pat-summary", - "No GitHub personal access token fallback is configured" - ) - - assert has_element?(view, "#setup-github-pat-note", "vcs/github/pat") - end - - test "saving a GitHub PAT persists encrypted secret storage and refreshes linked repositories", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - managed_app_env_keys = [ - :github_app_id, - :github_app_private_key, - :github_app_installation_token, - :github_app_accessible_repos, - :github_app_expected_repos, - :github_pat, - :github_pat_accessible_repos - ] - - managed_system_env_keys = [ - "GITHUB_APP_ID", - "GITHUB_APP_PRIVATE_KEY", - "GITHUB_APP_INSTALLATION_TOKEN", - "GITHUB_APP_ACCESSIBLE_REPOS", - "GITHUB_APP_EXPECTED_REPOS", - "GITHUB_PAT", - "GITHUB_PAT_ACCESSIBLE_REPOS" - ] - - original_app_env = - Enum.map(managed_app_env_keys, fn key -> - {key, Application.get_env(:jido_code, key, :__missing__)} - end) - - original_system_env = - Enum.map(managed_system_env_keys, fn key -> - {key, fetch_system_env(key)} - end) - - on_exit(fn -> - Enum.each(original_app_env, fn {key, value} -> - restore_env(key, value) - end) - - Enum.each(original_system_env, fn {key, value} -> - restore_system_env(key, value) - end) - end) - - Enum.each(managed_app_env_keys, &Application.delete_env(:jido_code, &1)) - Enum.each(managed_system_env_keys, &System.delete_env/1) - - test_pid = self() - - Application.put_env(:jido_code, :setup_github_http_client, fn :pat, "ghp_test_token", _opts -> - send(test_pid, :github_pat_repository_listing_requested) - - {:ok, - [ - %{id: "repo_100", full_name: "owner/repo-one"}, - %{id: "repo_200", full_name: "owner/repo-two"} - ]} - end) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - render_submit(view, "save_github_pat", %{"github_pat" => %{"value" => "ghp_test_token"}}) - - assert_receive :github_pat_repository_listing_requested - refute has_element?(view, "#setup-github-pat-panel") - - selector = vue(view, id: "setup-github-repository-selector") - - assert selector.props["listingStatus"] == "ready" - - assert Enum.map(selector.props["repositoryOptions"], & &1["fullName"]) == [ - "owner/repo-one", - "owner/repo-two" - ] - - assert {:ok, pat_secret} = - SecretRefs.operational_secret_value(:integration, "vcs/github/pat") - - assert pat_secret.value == "ghp_test_token" - assert pat_secret.source == :onboarding - - assert %{ - onboarding_state: %{ - "4" => %{ - "github_credentials" => %{ - "paths" => [ - %{ - "path" => "github_app", - "status" => "not_configured" - }, - %{ - "path" => "pat", - "repository_access" => "confirmed", - "repositories" => ["owner/repo-one", "owner/repo-two"], - "status" => "ready" - } - ], - "status" => "ready" - } - }, - "7" => %{ - "repository_listing" => %{ - "status" => "ready" - } - } - } - } = Application.get_env(:jido_code, :system_config) - end - - test "choosing GitHub surfaces encryption preflight before PAT save when secret storage is unavailable", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - original_encryption_key = Application.get_env(:jido_code, :secret_ref_encryption_key, :__missing__) - - on_exit(fn -> - restore_env(:secret_ref_encryption_key, original_encryption_key) - end) - - Application.delete_env(:jido_code, :secret_ref_encryption_key) - - test_pid = self() - - Application.put_env(:jido_code, :setup_github_http_client, fn _path, _token, _opts -> - send(test_pid, :github_pat_repository_listing_requested_unexpectedly) - {:ok, []} - end) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - assert has_element?(view, "#setup-github-pat-encryption-preflight") - assert has_element?(view, "#setup-github-repository-selector-deferred") - refute has_element?(view, "#setup-github-repository-selector") - assert render(view) =~ "Encrypted secret storage is unavailable in this running JidoCode process." - assert render(view) =~ "JIDO_CODE_SECRET_REF_ENCRYPTION_KEY" - - render_submit(view, "save_github_pat", %{"github_pat" => %{"value" => "ghp_test_token"}}) - - refute_received :github_pat_repository_listing_requested_unexpectedly - - assert has_element?( - view, - "#setup-github-pat-save-error", - "Encrypted secret storage is unavailable in this running JidoCode process." - ) - end - - test "choosing GitHub hydrates deployment-local credential checks when step 4 state is missing", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :setup_github_credential_checker, fn _context -> - %{ - checked_at: @checked_at, - status: :ready, - owner_context: "owner@example.com", - paths: [ - %{ - path: :github_app, - name: "GitHub App", - status: :ready, - previous_status: :not_configured, - transition: "activated", - owner_context: "owner@example.com", - repository_access: :confirmed, - repositories: ["owner/repo-one", "owner/repo-two"], - expected_repositories: [], - missing_repositories: [], - detail: "GitHub App credentials resolved with confirmed repository access.", - remediation: "Credential path is ready.", - error_type: nil, - validated_at: @checked_at, - checked_at: @checked_at - } - ] - } - end) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - selector = vue(view, id: "setup-github-repository-selector") - - assert selector.props["listingStatus"] == "ready" - - assert Enum.map(selector.props["repositoryOptions"], & &1["fullName"]) == [ - "owner/repo-one", - "owner/repo-two" - ] - - assert %{ - onboarding_state: %{ - "4" => %{ - "github_credentials" => %{ - "owner_context" => "owner@example.com", - "status" => "ready", - "paths" => [ - %{ - "path" => "github_app", - "repository_access" => "confirmed", - "repositories" => ["owner/repo-one", "owner/repo-two"], - "status" => "ready" - } - ] - } - } - } - } = Application.get_env(:jido_code, :system_config) - end - - test "fallback GitHub repository selector persists selection and import metadata when richer delivery degrades", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - Application.put_env(:jido_code, :setup_project_importer, fn context -> - selected_repository = context.selected_repository - repo_name = selected_repository |> String.split("/") |> List.last() - managed_repo_id = "managed-repo-#{repo_name}" - - %{ - checked_at: @checked_at, - status: :ready, - selected_repository: selected_repository, - project_record: %{ - id: managed_repo_id, - name: repo_name, - source_kind: :github, - source_identifier: selected_repository, - github_full_name: selected_repository, - local_path: nil, - default_branch: "main", - import_mode: :created, - imported_at: @checked_at, - clone_status: :ready, - clone_status_history: [ - %{status: :pending}, - %{status: :cloning}, - %{status: :ready} - ], - last_synced_at: @checked_at - }, - baseline_metadata: %{ - workspace_initialized: true, - baseline_synced: true, - default_workflow_registered: true, - agent_configuration_registered: true, - status: :ready, - initialized_at: @checked_at, - synced_branch: "main", - last_synced_at: @checked_at, - workspace_environment: :sprite, - workspace_path: nil - }, - detail: "Imported #{selected_repository} into the managed-repository control plane.", - remediation: "Import complete.", - error_type: nil - } - end) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - }, - "4" => %{ - "github_credentials" => %{ - "paths" => [ - %{ - "path" => "github_app", - "status" => "ready", - "repository_access" => "confirmed", - "repositories" => [ - %{"id" => "repo_100", "full_name" => "owner/repo-one"}, - %{"id" => "repo_200", "full_name" => "owner/repo-two"} - ] - } - ] - } - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - assert has_element?(view, "#setup-github-repository-selector-fallback") - assert has_element?(view, "#setup-github-repository-selector-fallback-body") - assert has_element?(view, "#setup-github-repository-fallback-title", "Choose GitHub repositories") - assert has_element?(view, "#setup-github-repository-fallback-badge", "Optional follow-up") - - assert has_element?( - view, - "#setup-github-repository-fallback-summary", - "Pick one or more linked GitHub repositories and import them into the control plane." - ) - - assert has_element?( - view, - "#setup-github-repository-fallback-boundary-note", - "LiveView still owns PAT capture, persistence, and completion; this widget only makes repository follow-up easier to scan and resume." - ) - - assert has_element?(view, "#setup-github-repository-fallback-list") - - assert has_element?( - view, - "#setup-github-repository-fallback-count", - "2 linked repositories available for import." - ) - - assert has_element?(view, "#setup-github-repository-fallback-account-filter") - assert has_element?(view, "#setup-github-repository-fallback-search") - - view - |> element("#setup-github-repository-fallback-option-repo_100") - |> render_click() - - assert %{ - onboarding_state: %{ - "7" => %{ - "selected_repository" => "owner/repo-one", - "selected_repositories" => ["owner/repo-one"] - } - } - } = Application.get_env(:jido_code, :system_config) - - view - |> element("#setup-github-repository-fallback-option-repo_200") - |> render_click() - - assert %{ - onboarding_state: %{ - "7" => %{ - "selected_repository" => "owner/repo-one", - "selected_repositories" => ["owner/repo-one", "owner/repo-two"] - } - } - } = Application.get_env(:jido_code, :system_config) - - view - |> form("#setup-github-repository-selector-fallback-form", %{ - "repository_selection" => %{"repository_full_names" => ["owner/repo-one", "owner/repo-two"]} - }) - |> render_submit() - - assert %{ - onboarding_state: %{ - "7" => step_state - } - } = Application.get_env(:jido_code, :system_config) - - assert step_state["project_import"]["selected_repository"] == "owner/repo-two" - assert step_state["project_import"]["project_record"]["id"] == "managed-repo-repo-two" - assert step_state["project_import"]["status"] == "ready" - assert step_state["project_import_batch"]["selected_repositories"] == ["owner/repo-one", "owner/repo-two"] - assert step_state["project_import_batch"]["imported_repositories"] == ["owner/repo-one", "owner/repo-two"] - assert step_state["project_import_batch"]["status"] == "ready" - assert step_state["repository_selection_note"] == "GitHub repository selection cleared." - refute Map.has_key?(step_state, "selected_repository") - refute Map.has_key?(step_state, "selected_repositories") - - assert has_element?(view, "#setup-github-import-fallback-success") - - assert has_element?( - view, - "#setup-github-import-fallback-success", - "Imported 2 GitHub repositories into the managed-repository control plane." - ) - - refute has_element?(view, "#setup-github-import-fallback-open-repo") - assert has_element?(view, "#setup-github-repository-fallback-selection", "Not selected") - end - - test "fallback GitHub repository selector supports account filtering and matching search without rewriting saved selection", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :frontend_assets_override, %{ - mode: :fallback, - reason: :asset_manifest_unavailable - }) - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - }, - "4" => %{ - "github_credentials" => %{ - "paths" => [ - %{ - "path" => "github_app", - "status" => "ready", - "repository_access" => "confirmed", - "repositories" => [ - %{"id" => "repo_100", "full_name" => "owner/repo-one"}, - %{"id" => "repo_200", "full_name" => "owner/repo-two"}, - %{"id" => "repo_300", "full_name" => "agentjido/repo-three"} - ] - } - ] - } - } - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - assert has_element?( - view, - "#setup-github-repository-fallback-results-summary", - "Showing all 3 linked repositories." - ) - - view - |> element("#setup-github-repository-fallback-option-repo_100") - |> render_click() - - assert has_element?( - view, - "#setup-github-repository-fallback-selection", - "owner/repo-one" - ) - - view - |> form("#setup-github-repository-fallback-filter-form", %{ - "repository_filters" => %{"owner" => "agentjido", "query" => ""} - }) - |> render_change() - - refute has_element?(view, "#setup-github-repository-fallback-option-repo_100") - refute has_element?(view, "#setup-github-repository-fallback-option-repo_200") - assert has_element?(view, "#setup-github-repository-fallback-option-repo_300") - - assert has_element?( - view, - "#setup-github-repository-fallback-results-summary", - "Showing 1 of 3 linked repositories." - ) - - assert has_element?( - view, - "#setup-github-repository-fallback-selection", - "owner/repo-one" - ) - - assert %{ - onboarding_state: %{ - "7" => %{ - "selected_repository" => "owner/repo-one", - "selected_repositories" => ["owner/repo-one"] - } - } - } = Application.get_env(:jido_code, :system_config) - - view - |> form("#setup-github-repository-fallback-filter-form", %{ - "repository_filters" => %{"owner" => "", "query" => "repo-two"} - }) - |> render_change() - - refute has_element?(view, "#setup-github-repository-fallback-option-repo_100") - assert has_element?(view, "#setup-github-repository-fallback-option-repo_200") - refute has_element?(view, "#setup-github-repository-fallback-option-repo_300") - - view - |> form("#setup-github-repository-fallback-filter-form", %{ - "repository_filters" => %{"owner" => "", "query" => "missing"} - }) - |> render_change() - - assert has_element?( - view, - "#setup-github-repository-fallback-empty", - "No linked repositories match the current filter." - ) - end - - test "completing setup after choosing GitHub marks onboarding complete and enters the dashboard", %{ - conn: conn - } do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 3, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true, - "validated_note" => "Owner account bootstrapped." - } - } - }) - - authed_conn = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - - {:ok, view, _html} = live(authed_conn, ~p"/setup", on_error: :warn) - - choose_start_path(view, "github") - - view - |> element("#setup-complete-continue") - |> render_click() - - assert_redirect(view, "/dashboard?onboarding=completed") - - assert %{ - onboarding_completed: true, - onboarding_step: 4, - onboarding_state: %{ - "1" => %{"validated_note" => "System prerequisites verified (welcome flow)."}, - "2" => %{"validated_note" => "Owner account bootstrapped."}, - "3" => %{ - "start_path" => "github", - "deployment_mode" => "cloud", - "validated_note" => "GitHub path selected.", - "completion_note" => completion_note - } - } - } = Application.get_env(:jido_code, :system_config) - - assert completion_note =~ "Setup completed." - - {:ok, dashboard_view, _html} = - live(recycle(authed_conn), ~p"/dashboard?onboarding=completed", on_error: :warn) - - assert has_element?(dashboard_view, "#dashboard-overview-panel") - end - - test "later onboarding states still render the simplified setup start surface instead of the old wizard", - %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: false, - onboarding_step: 7, - onboarding_state: %{ - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true - }, - "3" => %{ - "start_path" => "later", - "deployment_mode" => "cloud", - "validated_note" => "Repo setup deferred for now." - }, - "7" => %{"validated_note" => "Legacy import state retained for migration coverage."} - } - }) - - {:ok, view, _html} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - - assert_setup_start_surface(view) - assert runtime_defaults_widget(view).props["selectedStartPathLabel"] == "Do this later" - refute render(view) =~ "System checks" - refute render(view) =~ "First project import readiness" - end - - test "completed onboarding redirects signed-in sessions away from /setup to /dashboard", %{conn: conn} do - register_owner("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :system_config, %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{ - "2" => %{ - "owner_email" => "owner@example.com", - "owner_mode" => "created", - "registration_actions_disabled" => true - } - } - }) - - assert {:error, {:live_redirect, %{to: "/dashboard"}}} = - conn - |> authenticate_owner_conn("owner@example.com", "owner-password-123") - |> live(~p"/setup", on_error: :warn) - end - - defp attach_owner_recovery_audit_handler do - test_pid = self() - handler_id = "setup-owner-recovery-audit-handler-#{System.unique_integer([:positive])}" - - :ok = - :telemetry.attach( - handler_id, - @owner_recovery_audit_event, - fn event_name, measurements, metadata, _config -> - send(test_pid, {:owner_recovery_audit, event_name, measurements, metadata}) - end, - nil - ) - - on_exit(fn -> :telemetry.detach(handler_id) end) - end - - defp fetch_system_env(key) do - case System.get_env(key) do - nil -> :__missing__ - value -> value - end - end - - defp assert_setup_start_surface(view) do - assert has_element?(view, "#setup-start-surface") - assert has_element?(view, "#setup-title", "Choose how to start") - assert has_element?(view, "#setup-description") - assert_vue_component(view, "SetupRuntimeDefaultsWidget", id: "setup-runtime-defaults-widget") - assert_vue_component(view, "SetupStartPathSelectorWidget", id: "setup-start-path-selector") - - assert_vue_handler(view, "changeRuntimeEnvironment", "change_runtime_environment", - id: "setup-runtime-defaults-widget" - ) - - assert_vue_handler(view, "saveRuntimeEnvironment", "save_runtime_environment", id: "setup-runtime-defaults-widget") - - assert_vue_handler(view, "chooseStartPath", "choose_start_path", id: "setup-start-path-selector") - assert has_element?(view, "#setup-complete-continue", "Continue to dashboard") - assert runtime_defaults_widget(view).props["ownerEmail"] == "owner@example.com" - end - - defp runtime_defaults_widget(view), do: vue(view, id: "setup-runtime-defaults-widget") - - defp start_path_widget(view), do: vue(view, id: "setup-start-path-selector") - - defp start_option(widget, id) do - Enum.find(widget.props["options"], &(&1["id"] == id)) - end - - defp choose_start_path(view, choice) do - render_click(view, "choose_start_path", %{"choice" => choice}) - end - - defp tmp_workspace_path! do - workspace_path = - Path.join( - System.tmp_dir!(), - "setup-live-runtime-workspace-#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workspace_path) - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp assert_owner_count(expected_count) do - {:ok, owners} = Ash.read(User, domain: Accounts, authorize?: false) - assert length(owners) == expected_count - end - - defp assert_single_owner_admin!(expected_admin?) do - {:ok, [owner]} = Ash.read(User, domain: Accounts, authorize?: false) - assert owner.is_admin == expected_admin? - owner - end - - defp passing_prerequisite_report do - %{ - checked_at: @checked_at, - status: :pass, - checks: [ - %{ - id: "database_connectivity", - name: "Database connectivity", - status: :pass, - detail: "Successfully connected to Postgres.", - remediation: "Confirm Postgres is reachable and verify `DATABASE_URL` or Repo runtime config.", - checked_at: @checked_at - }, - %{ - id: "runtime_token_signing_secret", - name: "Runtime configuration: TOKEN_SIGNING_SECRET", - status: :pass, - detail: "Runtime configuration: TOKEN_SIGNING_SECRET is configured.", - remediation: "Set `TOKEN_SIGNING_SECRET` in runtime config (or env) and restart JidoCode.", - checked_at: @checked_at - } - ] - } - end - - defp redirect_path({path, _flash}) when is_binary(path), do: path - defp redirect_path(path) when is_binary(path), do: path - - defp reset_owner_state! do - Ecto.Adapters.SQL.query!(Repo, "TRUNCATE TABLE users RESTART IDENTITY CASCADE", []) - end -end diff --git a/test/jido_code_web/live/welcome_live_test.exs b/test/jido_code_web/live/welcome_live_test.exs deleted file mode 100644 index d6177865..00000000 --- a/test/jido_code_web/live/welcome_live_test.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule JidoCodeWeb.WelcomeLiveTest do - # covers: baseline.surface.welcome_landing_copy - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - test "/welcome renders the first-run bootstrap landing when no users exist", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/welcome") - - assert html =~ "Create your admin account" - end -end diff --git a/test/jido_code_web/live/workbench_live_test.exs b/test/jido_code_web/live/workbench_live_test.exs deleted file mode 100644 index 5f5c3d68..00000000 --- a/test/jido_code_web/live/workbench_live_test.exs +++ /dev/null @@ -1,2060 +0,0 @@ -defmodule JidoCodeWeb.WorkbenchLiveTest do - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - # covers: architecture.frontend_stack.server_authored_props_streams_and_events - # covers: architecture.source_code_graph_product_adoption.managed_repo_routes_host_semantic_inspection - # covers: architecture.source_code_graph_product_adoption.semantic_operator_surfaces_show_freshness_and_recovery - # covers: architecture.memory_graph_product_adoption.memory_operator_surfaces_show_freshness_validation_and_recovery - # covers: architecture.memory_graph_product_adoption.operator_surfaces_do_not_expose_raw_memory_graph_internals - # covers: architecture.conversation_orchestration.workbench_and_governed_run_surfaces_project_conversation_linkage - # covers: architecture.factory_control_plane.operator_surfaces_project_conversation_linkage_through_canonical_records - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias JidoCode.AgentWorkspace - alias JidoCode.Control.Actor - alias JidoCode.MemoryGraph - alias JidoCode.MemoryGraph.{CaptureEnvelope, DurableMemoryEnvelope} - alias JidoCode.Orchestration.{Run, RunPubSub, WorkflowRun} - alias JidoCode.Projects.Project - - setup do - original_workbench_loader = - Application.get_env(:jido_code, :workbench_inventory_loader, :__missing__) - - original_fix_workflow_launcher = - Application.get_env(:jido_code, :workbench_fix_workflow_launcher, :__missing__) - - original_issue_triage_workflow_launcher = - Application.get_env(:jido_code, :workbench_issue_triage_workflow_launcher, :__missing__) - - original_recent_run_outcome_loader = - Application.get_env(:jido_code, :workbench_recent_run_outcome_loader, :__missing__) - - original_system_config_loader = - Application.get_env(:jido_code, :system_config_loader, :__missing__) - - original_source_code_graph_enabled = - Application.get_env(:jido_code, :source_code_graph_enabled, false) - - original_memory_graph_enabled = - Application.get_env(:jido_code, :memory_graph_enabled, false) - - Application.put_env(:jido_code, :system_config_loader, fn -> - {:ok, - %{ - onboarding_completed: true, - onboarding_step: 8, - onboarding_state: %{}, - default_environment: :sprite, - workspace_root: nil - }} - end) - - on_exit(fn -> - restore_env(:workbench_inventory_loader, original_workbench_loader) - restore_env(:workbench_fix_workflow_launcher, original_fix_workflow_launcher) - - restore_env( - :workbench_issue_triage_workflow_launcher, - original_issue_triage_workflow_launcher - ) - - restore_env( - :workbench_recent_run_outcome_loader, - original_recent_run_outcome_loader - ) - - restore_env(:system_config_loader, original_system_config_loader) - Application.put_env(:jido_code, :source_code_graph_enabled, original_source_code_graph_enabled) - Application.put_env(:jido_code, :memory_graph_enabled, original_memory_graph_enabled) - end) - - :ok - end - - test "shows repo-scoped semantic readiness hints on managed repo rows", %{conn: _conn} do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - register_owner("workbench-semantic-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workbench-semantic-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("WorkbenchSemantic.Ready") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-semantic-ready", - github_full_name: "owner/repo-semantic-ready", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - assert {:ok, _load_result} = AgentWorkspace.load_source_code_graph(route_id, workspace_path) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-project-semantic-hint-#{route_id}") - - assert has_element?( - view, - "#workbench-project-semantic-hint-badge-#{route_id}", - "Semantic graph ready" - ) - - assert has_element?( - view, - "#workbench-project-semantic-hint-detail-#{route_id}", - "ready for inspection" - ) - - refute has_element?(view, "#workbench-project-semantic-hint-recovery-#{route_id}") - end - - test "surfaces stale semantic hints on workbench rows and routes recovery back to repo detail", %{ - conn: _conn - } do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - - register_owner("workbench-semantic-stale-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workbench-semantic-stale-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("WorkbenchSemantic.Stale") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-semantic-stale", - github_full_name: "owner/repo-semantic-stale", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - assert {:ok, _load_result} = AgentWorkspace.load_source_code_graph(route_id, workspace_path) - - rewrite_semantic_workspace_module!(workspace_path, "WorkbenchSemantic.Refreshed") - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-project-semantic-hint-#{route_id}") - - assert has_element?( - view, - "#workbench-project-semantic-hint-badge-#{route_id}", - "Semantic graph stale" - ) - - assert has_element?( - view, - "#workbench-project-semantic-hint-detail-#{route_id}", - "should be refreshed" - ) - - assert has_element?( - view, - "#workbench-project-semantic-hint-recovery-#{route_id}[href='/repos/#{route_id}?return_to=#{URI.encode_www_form("/workbench")}']", - "Open repo detail to refresh semantic graph data." - ) - end - - test "shows repo-scoped memory readiness hints on managed repo rows", %{conn: _conn} do - Application.put_env(:jido_code, :memory_graph_enabled, true) - - register_owner("workbench-memory-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workbench-memory-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("WorkbenchMemory.Ready") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-memory-ready", - github_full_name: "owner/repo-memory-ready", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - seed_memory_graph!(route_id, workspace_path, "phase-32-workbench-ready") - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-project-memory-hint-#{route_id}") - end - - test "surfaces repo-scoped workspace binding repair hints on workbench rows", %{conn: _conn} do - Application.put_env(:jido_code, :source_code_graph_enabled, true) - Application.put_env(:jido_code, :memory_graph_enabled, true) - - register_owner("workbench-binding-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workbench-binding-owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-binding-needed", - github_full_name: "owner/repo-binding-needed", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-project-semantic-hint-badge-#{route_id}", "Workspace binding needed") - - assert has_element?( - view, - "#workbench-project-semantic-hint-detail-#{route_id}", - "has no repo-scoped local workspace path bound for semantic inspection" - ) - - assert has_element?( - view, - "#workbench-project-semantic-hint-recovery-#{route_id}[href='/repos/#{route_id}?return_to=#{URI.encode_www_form("/workbench")}#project-detail-workspace-binding-panel']", - "Open repo detail to repair workspace binding." - ) - - assert has_element?(view, "#workbench-project-memory-hint-badge-#{route_id}", "Workspace binding needed") - - assert has_element?( - view, - "#workbench-project-memory-hint-detail-#{route_id}", - "has no repo-scoped local workspace path bound for memory inspection" - ) - - assert has_element?( - view, - "#workbench-project-memory-hint-recovery-#{route_id}[href='/repos/#{route_id}?return_to=#{URI.encode_www_form("/workbench")}#project-detail-workspace-binding-panel']", - "Open repo detail to repair workspace binding." - ) - end - - test "shows active repo conversation linkage on workbench rows", %{conn: _conn} do - register_owner("workbench-conversation-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workbench-conversation-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("WorkbenchConversation.Active") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-workbench-conversation", - github_full_name: "owner/repo-workbench-conversation", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - on_exit(fn -> - case AgentWorkspace.latest_repo_conversation(route_id, actor: Actor.operator_actor()) do - {:ok, %{id: conversation_id}} -> - _ = AgentWorkspace.stop_conversation(conversation_id) - _ = AgentWorkspace.shutdown_kernel(route_id) - :ok - - _other -> - _ = AgentWorkspace.shutdown_kernel(route_id) - :ok - end - end) - - {:ok, detail_view, _html} = - live(recycle(authed_conn), ~p"/repos/#{route_id}?section=conversations", on_error: :warn) - - detail_view - |> element("#project-detail-conversation-open") - |> render_click() - - detail_view - |> form("#project-detail-conversation-form", %{ - "input" => "Inspect the repo detail conversation flow." - }) - |> render_submit() - - assert_eventually(fn -> - has_element?(detail_view, "#project-detail-conversation-work-resolution", "created") and - has_element?(detail_view, "#project-detail-conversation-governed-work") - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-project-conversation-hint-#{route_id}") - - assert has_element?( - view, - "#workbench-project-conversation-role-#{route_id}", - "Governed conversation" - ) - - assert has_element?( - view, - "#workbench-project-conversation-status-#{route_id}", - "active" - ) - - assert has_element?( - view, - "#workbench-project-conversation-hint-badge-#{route_id}", - "1 active work item" - ) - - assert has_element?( - view, - "#workbench-project-conversation-hint-detail-#{route_id}" - ) - - assert has_element?( - view, - "#workbench-project-conversation-work-item-#{route_id}", - "Queue review operator request work for the managed repository." - ) - - assert has_element?( - view, - "#workbench-project-conversation-link-#{route_id}[href='/repos/#{route_id}?return_to=#{URI.encode_www_form("/workbench")}']", - "Open governed supervision" - ) - end - - test "surfaces stale memory hints on workbench rows and routes recovery back to repo detail", %{ - conn: _conn - } do - Application.put_env(:jido_code, :memory_graph_enabled, true) - - register_owner("workbench-memory-stale-owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("workbench-memory-stale-owner@example.com", "owner-password-123") - - workspace_path = create_semantic_workspace_path!("WorkbenchMemory.Stale") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-memory-stale", - github_full_name: "owner/repo-memory-stale", - default_branch: "main", - settings: %{ - "workspace" => %{ - "workspace_environment" => "local", - "workspace_path" => workspace_path, - "clone_status" => "ready", - "workspace_initialized" => true, - "baseline_synced" => true - } - } - }) - - seed_memory_graph!(route_id, workspace_path, "phase-32-workbench-stale") - rewrite_semantic_workspace_module!(workspace_path, "WorkbenchMemory.Refreshed") - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-project-memory-hint-#{route_id}") - - assert has_element?( - view, - "#workbench-project-memory-hint-badge-#{route_id}", - "Memory graph stale" - ) - - assert has_element?( - view, - "#workbench-project-memory-hint-detail-#{route_id}", - "stale" - ) - - assert has_element?( - view, - "#workbench-project-memory-hint-recovery-#{route_id}[href='/repos/#{route_id}?return_to=#{URI.encode_www_form("/workbench")}']", - "Open repo detail to review governed memory context and validate memory graph" - ) - end - - test "renders cross-project workbench inventory rows with issue and PR counts plus activity summary", - %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - repo_one_id = "owner-repo-one" - repo_two_id = "owner-repo-two" - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: repo_one_id, - name: "repo-one", - github_full_name: "owner/repo-one", - open_issue_count: 12, - open_pr_count: 3, - recent_activity_summary: "Triaged issues and queued follow-up in the last hour." - }, - %{ - id: repo_two_id, - name: "repo-two", - github_full_name: "owner/repo-two", - open_issue_count: 4, - open_pr_count: 1, - recent_activity_summary: "Last activity: 2026-02-13 16:00:00Z" - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - view, - ~s|#workbench-breadcrumb-dashboard-work[href="/dashboard?subject=work§ion=overview"]|, - "Dashboard Work" - ) - - assert has_element?(view, "#workbench-breadcrumb-current[aria-current='page']", "Workbench") - assert has_element?(view, "#workbench-route-role-label", "Dense specialist mode") - - assert has_element?( - view, - ~s|#workbench-return-dashboard[href="/dashboard?subject=work§ion=overview"]|, - "Return to Dashboard Work" - ) - - assert has_element?( - view, - ~s|#workbench-dashboard-handoff a[href="/dashboard?subject=work§ion=overview"]|, - "Dashboard Work" - ) - - assert has_element?(view, "#workbench-project-table") - assert has_element?(view, "#workbench-project-name-#{repo_one_id}", "owner/repo-one") - assert has_element?(view, "#workbench-project-open-issues-#{repo_one_id}", "12") - assert has_element?(view, "#workbench-project-open-prs-#{repo_one_id}", "3") - assert has_element?(view, "#workbench-project-issues-github-link-#{repo_one_id}") - assert has_element?(view, "#workbench-project-issues-project-link-#{repo_one_id}") - assert has_element?(view, "#workbench-project-prs-github-link-#{repo_one_id}") - assert has_element?(view, "#workbench-project-prs-project-link-#{repo_one_id}") - - assert has_element?( - view, - "#workbench-project-issues-github-link-#{repo_one_id}[href='https://github.com/owner/repo-one/issues']" - ) - - assert has_element?( - view, - "#workbench-project-prs-github-link-#{repo_one_id}[href='https://github.com/owner/repo-one/pulls']" - ) - - assert has_element?( - view, - "#workbench-project-issues-project-link-#{repo_one_id}[href='/repos/#{repo_one_id}?return_to=#{URI.encode_www_form("/workbench")}']" - ) - - assert has_element?( - view, - "#workbench-project-prs-project-link-#{repo_one_id}[href='/repos/#{repo_one_id}?return_to=#{URI.encode_www_form("/workbench")}']" - ) - - assert has_element?( - view, - "#workbench-project-recent-activity-#{repo_one_id}", - "Triaged issues and queued follow-up in the last hour." - ) - - assert has_element?(view, "#workbench-project-name-#{repo_two_id}", "owner/repo-two") - assert has_element?(view, "#workbench-project-open-issues-#{repo_two_id}", "4") - assert has_element?(view, "#workbench-project-open-prs-#{repo_two_id}", "1") - assert has_element?(view, "#workbench-project-issues-github-link-#{repo_two_id}") - assert has_element?(view, "#workbench-project-issues-project-link-#{repo_two_id}") - assert has_element?(view, "#workbench-project-prs-github-link-#{repo_two_id}") - assert has_element?(view, "#workbench-project-prs-project-link-#{repo_two_id}") - - assert has_element?( - view, - "#workbench-project-issues-github-link-#{repo_two_id}[href='https://github.com/owner/repo-two/issues']" - ) - - assert has_element?( - view, - "#workbench-project-prs-github-link-#{repo_two_id}[href='https://github.com/owner/repo-two/pulls']" - ) - - assert has_element?( - view, - "#workbench-project-issues-project-link-#{repo_two_id}[href='/repos/#{repo_two_id}?return_to=#{URI.encode_www_form("/workbench")}']" - ) - - assert has_element?( - view, - "#workbench-project-prs-project-link-#{repo_two_id}[href='/repos/#{repo_two_id}?return_to=#{URI.encode_www_form("/workbench")}']" - ) - - assert has_element?(view, "#workbench-project-recent-activity-#{repo_two_id}") - - refute has_element?(view, "#workbench-stale-warning") - end - - test "renders recent run outcome indicators with run detail links for issue and PR context rows", - %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - {:ok, project} = - Project.create(%{ - name: "repo-run-outcomes", - github_full_name: "owner/repo-run-outcomes", - default_branch: "main", - settings: %{} - }) - - %{managed_repo: managed_repo, route_id: route_id} = - provision_managed_repo!(%{ - legacy_project_id: project.id, - name: "repo-run-outcomes", - github_full_name: "owner/repo-run-outcomes", - default_branch: "main", - settings: %{ - "inventory" => %{ - "open_issue_count" => 2, - "open_pr_count" => 1, - "recent_activity_summary" => "Recent automation signals are available." - } - } - }) - - now = DateTime.utc_now() |> DateTime.truncate(:second) - started_at = DateTime.add(now, -180, :second) - completed_at = DateTime.add(now, -60, :second) - - {:ok, workflow_run} = - WorkflowRun.create( - %{ - project_id: project.id, - managed_repo_id: managed_repo.id, - run_id: "workbench-recent-run-#{System.unique_integer([:positive])}", - workflow_name: "fix_failing_tests", - workflow_version: 1, - trigger: %{source: "workbench", mode: "manual"}, - inputs: %{"failure_signal" => "workbench indicator test"}, - input_metadata: %{ - "failure_signal" => %{required: true, source: "workbench_quick_action"} - }, - initiating_actor: %{id: "owner-1", email: "owner@example.com"}, - current_step: "publish_pr", - started_at: started_at - }, - actor: Actor.operator_actor() - ) - - {:ok, workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :running, - current_step: "publish_pr", - transitioned_at: DateTime.add(started_at, 30, :second) - }) - - {:ok, _workflow_run} = - WorkflowRun.transition_status(workflow_run, %{ - to_status: :completed, - current_step: "publish_pr", - transitioned_at: completed_at - }) - - {:ok, completed_run} = - Run.get_by_workflow_run_id( - workflow_run.id, - actor: Actor.operator_actor() - ) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - encoded_workbench_return_to = URI.encode_www_form("/workbench") - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-status", - "completed" - ) - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-link[href='/repos/#{route_id}/runs/#{completed_run.run_id}?return_to=#{encoded_workbench_return_to}']" - ) - - assert has_element?( - view, - "#workbench-project-prs-run-outcome-#{route_id}-status", - "completed" - ) - - assert has_element?( - view, - "#workbench-project-prs-run-outcome-#{route_id}-link[href='/repos/#{route_id}/runs/#{completed_run.run_id}?return_to=#{encoded_workbench_return_to}']" - ) - end - - test "refreshes run outcome indicators after kickoff and terminal run events", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-run-refresh", - github_full_name: "owner/repo-run-refresh", - default_branch: "main", - settings: %{ - "inventory" => %{ - "open_issue_count" => 3, - "open_pr_count" => 2, - "recent_activity_summary" => "Outcome refresh signals are active." - } - } - }) - - encoded_workbench_return_to = URI.encode_www_form("/workbench") - run_id = "workbench-refresh-run-#{System.unique_integer([:positive])}" - run_detail_path = "/repos/#{route_id}/runs/#{run_id}?return_to=#{encoded_workbench_return_to}" - loader_state = start_supervised!({Agent, fn -> :initial end}, id: make_ref()) - - Application.put_env(:jido_code, :workbench_recent_run_outcome_loader, fn _rows -> - case Agent.get(loader_state, & &1) do - :terminal -> - %{ - route_id => %{ - status: "completed", - run_id: run_id, - detail_path: run_detail_path - } - } - - _other -> - %{} - end - end) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn _kickoff_request -> - {:ok, %{run_id: run_id}} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-status", - "No recent run" - ) - - view - |> element("#workbench-project-issues-fix-action-#{route_id}") - |> render_click() - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-status", - "pending" - ) - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-link[href='#{run_detail_path}']" - ) - - Agent.update(loader_state, fn _current_state -> :terminal end) - - assert :ok = - RunPubSub.broadcast_run_event(run_id, %{ - "event" => "run_completed", - "run_id" => run_id - }) - - assert_eventually(fn -> - has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-status", - "completed" - ) - end) - - assert has_element?( - view, - "#workbench-project-prs-run-outcome-#{route_id}-status", - "completed" - ) - end - - test "shows unknown run outcome state with refresh guidance when status is unresolved", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-unknown-outcome", - github_full_name: "owner/repo-unknown-outcome", - default_branch: "main", - settings: %{ - "inventory" => %{ - "open_issue_count" => 4, - "open_pr_count" => 0, - "recent_activity_summary" => "Run status resolution failed." - } - } - }) - - Application.put_env(:jido_code, :workbench_recent_run_outcome_loader, fn _rows -> - %{ - route_id => %{ - status: "mystery_status", - run_id: "run-unknown-#{System.unique_integer([:positive])}", - detail: "Recent run metadata is incomplete.", - error_type: "workbench_recent_run_status_unresolved", - guidance: "Refresh workbench data to resolve recent run status." - } - } - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-status", - "unknown" - ) - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-error-type", - "workbench_recent_run_status_unresolved" - ) - - assert has_element?( - view, - "#workbench-project-issues-run-outcome-#{route_id}-guidance", - "Refresh workbench data" - ) - end - - test "shows stale-state warning and recovery actions when workbench data fetch fails", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - # LiveView mounts twice (disconnected + connected), so fail both initial loads - # and recover on explicit retry. - loader_state = start_supervised!({Agent, fn -> 0 end}) - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - Agent.get_and_update(loader_state, fn - call_count when call_count < 2 -> - warning = %{ - error_type: "workbench_data_fetch_failed", - detail: "GitHub inventory sync timed out and current counts may be stale.", - remediation: "Retry workbench fetch and review setup diagnostics if the issue persists." - } - - {{:error, warning}, call_count + 1} - - call_count -> - rows = [ - %{ - id: "owner-repo-recovered", - name: "repo-recovered", - github_full_name: "owner/repo-recovered", - open_issue_count: 7, - open_pr_count: 2, - recent_activity_summary: "Recovery refresh completed." - } - ] - - {{:ok, rows, nil}, call_count + 1} - end) - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "#workbench-stale-warning") - assert has_element?(view, "#workbench-stale-warning-type", "workbench_data_fetch_failed") - assert has_element?(view, "#workbench-stale-warning-detail", "counts may be stale") - assert has_element?(view, "#workbench-stale-warning-remediation", "Retry workbench fetch") - assert has_element?(view, "#workbench-retry-fetch") - assert has_element?(view, "#workbench-open-setup-recovery") - - view - |> element("#workbench-retry-fetch") - |> render_click() - - refute has_element?(view, "#workbench-stale-warning") - - assert has_element?( - view, - "#workbench-project-name-owner-repo-recovered", - "owner/repo-recovered" - ) - - assert has_element?(view, "#workbench-project-open-issues-owner-repo-recovered", "7") - assert has_element?(view, "#workbench-project-open-prs-owner-repo-recovered", "2") - - assert has_element?( - view, - "#workbench-project-recent-activity-owner-repo-recovered", - "Recovery refresh completed." - ) - end - - test "shows disabled link states with explanations when row link targets are unavailable", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "", - name: "repo-with-missing-links", - github_full_name: "", - open_issue_count: 2, - open_pr_count: 1, - recent_activity_summary: "No metadata available for links." - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - view, - "[id^='workbench-project-issues-github-disabled-workbench-row-'][aria-disabled='true']", - "GitHub Issues" - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-github-disabled-reason-workbench-row-']", - "GitHub repository URL is unavailable for this row." - ) - - assert has_element?( - view, - "[id^='workbench-project-prs-github-disabled-workbench-row-'][aria-disabled='true']", - "GitHub PRs" - ) - - assert has_element?( - view, - "[id^='workbench-project-prs-github-disabled-reason-workbench-row-']", - "GitHub repository URL is unavailable for this row." - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-project-disabled-workbench-row-'][aria-disabled='true']", - "Repo detail" - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-project-disabled-reason-workbench-row-']", - "Repo detail link is unavailable for this row." - ) - - assert has_element?( - view, - "[id^='workbench-project-prs-project-disabled-workbench-row-'][aria-disabled='true']", - "Repo detail" - ) - - assert has_element?( - view, - "[id^='workbench-project-prs-project-disabled-reason-workbench-row-']", - "Repo detail link is unavailable for this row." - ) - - refute has_element?(view, "[id^='workbench-project-issues-github-link-workbench-row-']") - refute has_element?(view, "[id^='workbench-project-prs-github-link-workbench-row-']") - refute has_element?(view, "[id^='workbench-project-issues-project-link-workbench-row-']") - refute has_element?(view, "[id^='workbench-project-prs-project-link-workbench-row-']") - end - - test "issue and PR quick actions kick off fix workflow runs with tracked run links", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-fixable", - github_full_name: "owner/repo-fixable", - default_branch: "main", - settings: %{ - "inventory" => %{ - "open_issue_count" => 5, - "open_pr_count" => 2, - "recent_activity_summary" => "Actionable queue." - } - } - }) - - launcher_requests = start_supervised!({Agent, fn -> [] end}) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn kickoff_request -> - Agent.update(launcher_requests, fn requests -> [kickoff_request | requests] end) - - run_id = - case kickoff_request.context_item.type do - :issue -> "run-issue-123" - :pull_request -> "run-pr-456" - end - - {:ok, %{run_id: run_id}} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - encoded_workbench_return_to = URI.encode_www_form("/workbench") - - assert has_element?(view, "#workbench-project-issues-fix-action-#{route_id}") - assert has_element?(view, "#workbench-project-prs-fix-action-#{route_id}") - - view - |> element("#workbench-project-issues-fix-action-#{route_id}") - |> render_click() - - assert has_element?( - view, - "#workbench-project-issues-fix-#{route_id}-run-id", - "run-issue-123" - ) - - assert has_element?( - view, - "#workbench-project-issues-fix-#{route_id}-run-link[href='/repos/#{route_id}/runs/run-issue-123?return_to=#{encoded_workbench_return_to}']" - ) - - refute has_element?(view, "#workbench-project-issues-fix-#{route_id}-error-type") - - view - |> element("#workbench-project-prs-fix-action-#{route_id}") - |> render_click() - - assert has_element?(view, "#workbench-project-prs-fix-#{route_id}-run-id", "run-pr-456") - - assert has_element?( - view, - "#workbench-project-prs-fix-#{route_id}-run-link[href='/repos/#{route_id}/runs/run-pr-456?return_to=#{encoded_workbench_return_to}']" - ) - - refute has_element?(view, "#workbench-project-prs-fix-#{route_id}-error-type") - - recorded_requests = launcher_requests |> Agent.get(&Enum.reverse(&1)) - - assert [ - %{ - project_id: ^route_id, - workflow_name: "fix_failing_tests", - context_item: %{type: :issue} - }, - %{ - project_id: ^route_id, - workflow_name: "fix_failing_tests", - context_item: %{type: :pull_request} - } - ] = recorded_requests - end - - test "issue triage quick action starts issue_triage with manual trigger metadata and initiating actor", - %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-triageable", - github_full_name: "owner/repo-triageable", - default_branch: "main", - settings: %{ - "inventory" => %{ - "open_issue_count" => 6, - "open_pr_count" => 1, - "recent_activity_summary" => "Issue queue needs triage." - }, - "support_agent_config" => %{ - "github_issue_bot" => %{ - "enabled" => true, - "approval_mode" => "auto_post" - } - } - } - }) - - launcher_requests = start_supervised!({Agent, fn -> [] end}) - - Application.put_env( - :jido_code, - :workbench_issue_triage_workflow_launcher, - fn kickoff_request -> - Agent.update(launcher_requests, fn requests -> [kickoff_request | requests] end) - {:ok, %{run_id: "run-triage-789"}} - end - ) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - encoded_workbench_return_to = URI.encode_www_form("/workbench") - - assert has_element?(view, "#workbench-project-issues-triage-action-#{route_id}") - - view - |> element("#workbench-project-issues-triage-action-#{route_id}") - |> render_click() - - assert has_element?( - view, - "#workbench-project-issues-triage-#{route_id}-run-id", - "run-triage-789" - ) - - assert has_element?( - view, - "#workbench-project-issues-triage-#{route_id}-run-link[href='/repos/#{route_id}/runs/run-triage-789?return_to=#{encoded_workbench_return_to}']" - ) - - refute has_element?(view, "#workbench-project-issues-triage-#{route_id}-error-type") - - recorded_requests = launcher_requests |> Agent.get(&Enum.reverse(&1)) - - assert [ - %{ - project_id: ^route_id, - workflow_name: "issue_triage", - context_item: %{type: :issue}, - trigger: %{ - source: "workbench", - mode: "manual", - source_row: %{ - route: "/workbench", - project_id: ^route_id, - context_item_type: :issue - }, - approval_policy: %{ - mode: "auto_post", - post_behavior: "auto_post", - auto_post: true, - requires_approval: false - } - }, - initiating_actor: %{id: actor_id} - } - ] = recorded_requests - - assert Map.has_key?(hd(recorded_requests).initiating_actor, :email) - assert is_binary(actor_id) - refute actor_id == "" - end - - test "triage action disabled by policy shows blocking policy state in workbench UI", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-policy-blocked", - name: "repo-policy-blocked", - github_full_name: "owner/repo-policy-blocked", - open_issue_count: 3, - open_pr_count: 0, - recent_activity_summary: "Policy blocks issue triage launches.", - issue_triage_policy: %{ - enabled: false, - policy: "support_agent_config.github_issue_bot.enabled", - error_type: "issue_triage_policy_disabled", - detail: "Issue triage workflow launches are disabled for this project.", - remediation: "Enable Issue Bot for this project to allow manual triage launches." - } - } - ], nil} - end) - - launcher_invocations = start_supervised!({Agent, fn -> 0 end}) - - Application.put_env( - :jido_code, - :workbench_issue_triage_workflow_launcher, - fn _kickoff_request -> - Agent.update(launcher_invocations, &(&1 + 1)) - {:ok, %{run_id: "unexpected-run"}} - end - ) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - view, - "#workbench-project-issues-triage-disabled-owner-repo-policy-blocked[aria-disabled='true']", - "Kick off issue triage workflow" - ) - - assert has_element?( - view, - "#workbench-project-issues-triage-disabled-owner-repo-policy-blocked-type", - "issue_triage_policy_disabled" - ) - - assert has_element?( - view, - "#workbench-project-issues-triage-disabled-owner-repo-policy-blocked-reason", - "disabled for this project" - ) - - assert has_element?( - view, - "#workbench-project-issues-triage-disabled-owner-repo-policy-blocked-remediation", - "Enable Issue Bot" - ) - - refute has_element?(view, "#workbench-project-issues-triage-action-owner-repo-policy-blocked") - assert Agent.get(launcher_invocations, & &1) == 0 - end - - test "inline kickoff validation failures render details and do not create runs", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "", - name: "repo-without-id", - github_full_name: "owner/repo-without-id", - open_issue_count: 1, - open_pr_count: 1, - recent_activity_summary: "Missing repo scope." - } - ], nil} - end) - - launcher_invocations = start_supervised!({Agent, fn -> 0 end}) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn _kickoff_request -> - Agent.update(launcher_invocations, &(&1 + 1)) - {:ok, %{run_id: "unexpected-run"}} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?(view, "[id^='workbench-project-issues-fix-action-workbench-row-']") - assert has_element?(view, "[id^='workbench-project-prs-fix-action-workbench-row-']") - - view - |> element("[id^='workbench-project-issues-fix-action-workbench-row-']") - |> render_click() - - assert has_element?( - view, - "[id^='workbench-project-name-workbench-row-']", - "owner/repo-without-id" - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-fix-workbench-row-'][id$='-status']", - "Kickoff failed" - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-fix-workbench-row-'][id$='-error-type']", - "workbench_fix_workflow_validation_failed" - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-fix-workbench-row-'][id$='-error-detail']", - "synthetic" - ) - - assert has_element?( - view, - "[id^='workbench-project-issues-fix-workbench-row-'][id$='-error-remediation']" - ) - - refute has_element?( - view, - "[id^='workbench-project-issues-fix-workbench-row-'][id$='-run-id']" - ) - - assert Agent.get(launcher_invocations, & &1) == 0 - end - - test "network interruption after kickoff resolves explicit run creation state", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{route_id: route_id} = - provision_managed_repo!(%{ - name: "repo-network-resolution", - github_full_name: "owner/repo-network-resolution", - default_branch: "main", - settings: %{ - "inventory" => %{ - "open_issue_count" => 2, - "open_pr_count" => 1, - "recent_activity_summary" => "Kickoff retries are active." - } - } - }) - - Application.put_env(:jido_code, :workbench_fix_workflow_launcher, fn _kickoff_request -> - {:error, - %{ - error_type: "workbench_fix_workflow_kickoff_interrupted", - detail: "Network interruption occurred after kickoff request.", - remediation: "Open the resolved run and continue monitoring there.", - run_creation_state: :created, - run_id: "run-recovered-789" - }} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - view - |> element("#workbench-project-issues-fix-action-#{route_id}") - |> render_click() - - assert has_element?( - view, - "#workbench-project-name-#{route_id}", - "owner/repo-network-resolution" - ) - - assert has_element?( - view, - "#workbench-project-issues-fix-#{route_id}-status", - "Kickoff confirmed after interruption" - ) - - assert has_element?( - view, - "#workbench-project-issues-fix-#{route_id}-run-id", - "run-recovered-789" - ) - - assert has_element?( - view, - "#workbench-project-issues-fix-#{route_id}-run-link[href='/repos/#{route_id}/runs/run-recovered-789?return_to=#{URI.encode_www_form("/workbench")}']" - ) - - refute has_element?(view, "#workbench-project-issues-fix-#{route_id}-error-type") - end - - test "applies project, state, and freshness filters without route changes", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - now = DateTime.utc_now() - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-alpha", - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - open_issue_count: 5, - open_pr_count: 0, - recent_activity_summary: "Alpha summary", - recent_activity_at: DateTime.add(now, -45 * 24 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: "owner-repo-beta", - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 0, - open_pr_count: 4, - recent_activity_summary: "Beta summary", - recent_activity_at: DateTime.add(now, -2 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: "owner-repo-gamma", - name: "repo-gamma", - github_full_name: "owner/repo-gamma", - open_issue_count: 2, - open_pr_count: 1, - recent_activity_summary: "Gamma summary", - recent_activity_at: DateTime.add(now, -10 * 24 * 60 * 60, :second) |> DateTime.to_iso8601() - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - vue = assert_vue_component(view, "WorkbenchSummaryWidget", id: "workbench-summary-widget") - - assert vue.props["inventoryCount"] == 3 - assert vue.props["inventoryTotalCount"] == 3 - assert vue.props["resetVisible"] == false - assert_vue_handler(view, "resetFilters", "reset_filters", id: "workbench-summary-widget") - - assert has_element?(view, "#workbench-filters-form") - assert has_element?(view, "#workbench-project-name-owner-repo-alpha", "owner/repo-alpha") - assert has_element?(view, "#workbench-project-name-owner-repo-beta", "owner/repo-beta") - assert has_element?(view, "#workbench-project-name-owner-repo-gamma", "owner/repo-gamma") - assert has_element?(view, "#workbench-filter-results-count", "Showing 3 of 3") - - apply_workbench_filters(view, %{"project_id" => "owner-repo-beta"}) - - assert has_element?(view, "#workbench-filters-form") - assert has_element?(view, "#workbench-project-name-owner-repo-beta", "owner/repo-beta") - refute has_element?(view, "#workbench-project-name-owner-repo-alpha") - refute has_element?(view, "#workbench-project-name-owner-repo-gamma") - assert has_element?(view, "#workbench-filter-chip-project", "owner/repo-beta") - assert has_element?(view, "#workbench-filter-results-count", "Showing 1 of 3") - - filtered_vue = vue(view, id: "workbench-summary-widget") - - assert filtered_vue.props["inventoryCount"] == 1 - assert filtered_vue.props["resetVisible"] == true - - apply_workbench_filters(view, %{"work_state" => "issues_open"}) - - assert has_element?(view, "#workbench-filters-form") - assert has_element?(view, "#workbench-project-name-owner-repo-alpha", "owner/repo-alpha") - refute has_element?(view, "#workbench-project-name-owner-repo-beta") - assert has_element?(view, "#workbench-project-name-owner-repo-gamma", "owner/repo-gamma") - assert has_element?(view, "#workbench-filter-chip-work-state", "Issues open") - assert has_element?(view, "#workbench-filter-results-count", "Showing 2 of 3") - - apply_workbench_filters(view, %{"freshness_window" => "stale_30d"}) - - assert has_element?(view, "#workbench-filters-form") - assert has_element?(view, "#workbench-project-name-owner-repo-alpha", "owner/repo-alpha") - refute has_element?(view, "#workbench-project-name-owner-repo-beta") - refute has_element?(view, "#workbench-project-name-owner-repo-gamma") - assert has_element?(view, "#workbench-filter-chip-freshness-window", "Stale for 30+ days") - assert has_element?(view, "#workbench-filter-results-count", "Showing 1 of 3") - end - - test "supports backlog and recent activity sort ordering with deterministic results", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - now = DateTime.utc_now() - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-alpha", - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - open_issue_count: 2, - open_pr_count: 1, - recent_activity_summary: "Alpha summary", - recent_activity_at: DateTime.add(now, -2 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: "owner-repo-beta", - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 5, - open_pr_count: 4, - recent_activity_summary: "Beta summary", - recent_activity_at: DateTime.add(now, -9 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: "owner-repo-gamma", - name: "repo-gamma", - github_full_name: "owner/repo-gamma", - open_issue_count: 1, - open_pr_count: 0, - recent_activity_summary: "Gamma summary", - recent_activity_at: DateTime.add(now, -60 * 60, :second) |> DateTime.to_iso8601() - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - assert has_element?( - view, - "#workbench-filter-sort-order option[value='backlog_desc']", - "Backlog size (highest first)" - ) - - assert has_element?( - view, - "#workbench-filter-sort-order option[value='recent_activity_desc']", - "Recent activity (most recent first)" - ) - - apply_workbench_filters(view, %{"sort_order" => "backlog_desc"}) - - assert_project_row_order(view, ["owner-repo-beta", "owner-repo-alpha", "owner-repo-gamma"]) - - apply_workbench_filters(view, %{"sort_order" => "backlog_desc"}) - - assert_project_row_order(view, ["owner-repo-beta", "owner-repo-alpha", "owner-repo-gamma"]) - - apply_workbench_filters(view, %{"sort_order" => "recent_activity_desc"}) - - assert_project_row_order(view, ["owner-repo-gamma", "owner-repo-alpha", "owner-repo-beta"]) - - assert has_element?( - view, - "#workbench-filter-chip-sort-order", - "Recent activity (most recent first)" - ) - end - - test "keeps selected sort order after retry refresh events", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - stale_warning = %{ - error_type: "workbench_refresh_state_warning", - detail: "Refresh is available.", - remediation: "Use retry to refresh data." - } - - loader_state = start_supervised!({Agent, fn -> 0 end}) - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - Agent.get_and_update(loader_state, fn call_count -> - rows = - if call_count < 2 do - [ - %{ - id: "owner-repo-alpha", - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - open_issue_count: 5, - open_pr_count: 0, - recent_activity_summary: "Alpha summary" - }, - %{ - id: "owner-repo-beta", - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 2, - open_pr_count: 0, - recent_activity_summary: "Beta summary" - }, - %{ - id: "owner-repo-gamma", - name: "repo-gamma", - github_full_name: "owner/repo-gamma", - open_issue_count: 1, - open_pr_count: 0, - recent_activity_summary: "Gamma summary" - } - ] - else - [ - %{ - id: "owner-repo-alpha", - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - open_issue_count: 0, - open_pr_count: 0, - recent_activity_summary: "Alpha refreshed summary" - }, - %{ - id: "owner-repo-beta", - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 4, - open_pr_count: 2, - recent_activity_summary: "Beta refreshed summary" - }, - %{ - id: "owner-repo-gamma", - name: "repo-gamma", - github_full_name: "owner/repo-gamma", - open_issue_count: 3, - open_pr_count: 0, - recent_activity_summary: "Gamma refreshed summary" - } - ] - end - - {{:ok, rows, stale_warning}, call_count + 1} - end) - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - apply_workbench_filters(view, %{"sort_order" => "backlog_desc"}) - assert_project_row_order(view, ["owner-repo-alpha", "owner-repo-beta", "owner-repo-gamma"]) - - view - |> element("#workbench-retry-fetch") - |> render_click() - - assert_project_row_order(view, ["owner-repo-beta", "owner-repo-gamma", "owner-repo-alpha"]) - - assert has_element?( - view, - "#workbench-filter-sort-order option[value='backlog_desc'][selected]" - ) - - assert has_element?(view, "#workbench-filter-chip-sort-order", "Backlog size (highest first)") - end - - test "falls back to default sort order with notice when sort data is malformed", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-beta", - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 3, - open_pr_count: 1, - recent_activity_summary: "Beta summary" - }, - :invalid_row_payload - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - refute has_element?(view, "#workbench-sort-validation-notice") - - apply_workbench_filters(view, %{"sort_order" => "backlog_desc"}) - - assert has_element?(view, "#workbench-sort-validation-notice") - - assert has_element?( - view, - "#workbench-sort-validation-type", - "workbench_sort_order_fallback" - ) - - assert has_element?(view, "#workbench-sort-validation-detail", "Backlog size (highest first)") - assert has_element?(view, "#workbench-filter-chip-sort-order", "Repository name (A-Z)") - - assert has_element?( - view, - "#workbench-filter-sort-order option[value='project_name_asc'][selected]" - ) - - assert_project_row_order(view, ["owner-repo-beta", "workbench-row-2"]) - end - - test "invalid filter values reset defaults and show typed validation notice", %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-one", - name: "repo-one", - github_full_name: "owner/repo-one", - open_issue_count: 1, - open_pr_count: 0, - recent_activity_summary: "Repo one summary" - }, - %{ - id: "owner-repo-two", - name: "repo-two", - github_full_name: "owner/repo-two", - open_issue_count: 0, - open_pr_count: 2, - recent_activity_summary: "Repo two summary" - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - view - |> element("#workbench-filters-form") - |> render_change(%{ - "filters" => %{ - "project_id" => "owner-repo-missing", - "work_state" => "broken-state", - "freshness_window" => "future-only" - } - }) - - assert has_element?(view, "#workbench-filter-validation-notice") - - assert has_element?( - view, - "#workbench-filter-validation-type", - "workbench_filter_values_invalid" - ) - - assert has_element?(view, "#workbench-filter-validation-detail", "project_id") - assert has_element?(view, "#workbench-filter-chip-project", "All repositories") - assert has_element?(view, "#workbench-filter-chip-work-state", "Any issue or PR state") - assert has_element?(view, "#workbench-filter-chip-freshness-window", "Any freshness") - assert has_element?(view, "#workbench-filter-chip-sort-order", "Repository name (A-Z)") - assert has_element?(view, "#workbench-filter-results-count", "Showing 2 of 2") - assert has_element?(view, "#workbench-project-name-owner-repo-one", "owner/repo-one") - assert has_element?(view, "#workbench-project-name-owner-repo-two", "owner/repo-two") - assert has_element?(view, "#workbench-filter-project option[value='all'][selected]") - assert has_element?(view, "#workbench-filter-work-state option[value='all'][selected]") - assert has_element?(view, "#workbench-filter-freshness-window option[value='any'][selected]") - - assert has_element?( - view, - "#workbench-filter-sort-order option[value='project_name_asc'][selected]" - ) - end - - test "preserves filter and sort state across navigation context and restores from workbench URL", - %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - now = DateTime.utc_now() - - %{route_id: alpha_id} = - provision_managed_repo!(%{ - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - default_branch: "main", - settings: %{} - }) - - %{route_id: beta_id} = - provision_managed_repo!(%{ - name: "repo-beta", - github_full_name: "owner/repo-beta", - default_branch: "main", - settings: %{} - }) - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: alpha_id, - name: "repo-alpha", - github_full_name: "owner/repo-alpha", - open_issue_count: 2, - open_pr_count: 0, - recent_activity_summary: "Alpha summary", - recent_activity_at: DateTime.add(now, -10 * 24 * 60 * 60, :second) |> DateTime.to_iso8601() - }, - %{ - id: beta_id, - name: "repo-beta", - github_full_name: "owner/repo-beta", - open_issue_count: 0, - open_pr_count: 3, - recent_activity_summary: "Beta summary", - recent_activity_at: DateTime.add(now, -3 * 60 * 60, :second) |> DateTime.to_iso8601() - } - ], nil} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workbench", on_error: :warn) - - apply_workbench_filters(view, %{ - "project_id" => beta_id, - "work_state" => "prs_open", - "freshness_window" => "active_24h", - "sort_order" => "recent_activity_desc" - }) - - workbench_state_path = - JidoCode.ManagedRepoRoutes.workbench_path(%{ - project_id: beta_id, - work_state: "prs_open", - freshness_window: "active_24h", - sort_order: "recent_activity_desc" - }) - - assert_patch(view, workbench_state_path) - assert has_element?(view, "#workbench-project-name-#{beta_id}") - refute has_element?(view, "#workbench-project-name-#{alpha_id}") - - repo_detail_path = - JidoCode.ManagedRepoRoutes.project_detail_path( - beta_id, - return_to: workbench_state_path - ) - - assert has_element?( - view, - "#workbench-project-issues-project-link-#{beta_id}[href='#{repo_detail_path}']" - ) - - assert has_element?( - view, - "#workbench-project-prs-project-link-#{beta_id}[href='#{repo_detail_path}']" - ) - - {:ok, restored_view, _html} = - live(recycle(authed_conn), workbench_state_path, on_error: :warn) - - assert has_element?(restored_view, "#workbench-project-name-#{beta_id}") - refute has_element?(restored_view, "#workbench-project-name-#{alpha_id}") - assert has_element?(restored_view, "#workbench-filter-chip-project", "owner/repo-beta") - assert has_element?(restored_view, "#workbench-filter-chip-work-state", "PRs open") - - assert has_element?( - restored_view, - "#workbench-filter-chip-freshness-window", - "Active in last 24 hours" - ) - - assert has_element?( - restored_view, - "#workbench-filter-chip-sort-order", - "Recent activity (most recent first)" - ) - - assert has_element?( - restored_view, - "#workbench-filter-project option[value='#{beta_id}'][selected]" - ) - - assert has_element?( - restored_view, - "#workbench-filter-work-state option[value='prs_open'][selected]" - ) - - assert has_element?( - restored_view, - "#workbench-filter-freshness-window option[value='active_24h'][selected]" - ) - - assert has_element?( - restored_view, - "#workbench-filter-sort-order option[value='recent_activity_desc'][selected]" - ) - - restored_view - |> element("#workbench-project-issues-project-link-#{beta_id}") - |> render_click() - - assert_redirect( - restored_view, - repo_detail_path - ) - - {:ok, repo_view, _html} = - live( - recycle(authed_conn), - repo_detail_path, - on_error: :warn - ) - - assert has_element?(repo_view, "#project-detail-breadcrumb-return[href='#{workbench_state_path}']", "Workbench") - - assert has_element?( - repo_view, - "#project-detail-return-link[href='#{workbench_state_path}']", - "Back to Workbench" - ) - - repo_view - |> element("#project-detail-return-link") - |> render_click() - - assert_redirect(repo_view, workbench_state_path) - end - - test "invalid restored workbench state falls back to defaults with a reset reason notice", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - Application.put_env(:jido_code, :workbench_inventory_loader, fn -> - {:ok, - [ - %{ - id: "owner-repo-one", - name: "repo-one", - github_full_name: "owner/repo-one", - open_issue_count: 1, - open_pr_count: 0, - recent_activity_summary: "Repo one summary" - }, - %{ - id: "owner-repo-two", - name: "repo-two", - github_full_name: "owner/repo-two", - open_issue_count: 0, - open_pr_count: 2, - recent_activity_summary: "Repo two summary" - } - ], nil} - end) - - {:ok, view, _html} = - live( - recycle(authed_conn), - "/workbench?project_id=owner-repo-missing&sort_order=backlog_desc", - on_error: :warn - ) - - assert has_element?(view, "#workbench-filter-validation-notice") - - assert has_element?( - view, - "#workbench-filter-validation-type", - "workbench_filter_restore_state_invalid" - ) - - assert has_element?(view, "#workbench-filter-validation-detail", "project_id") - assert has_element?(view, "#workbench-filter-chip-project", "All repositories") - assert has_element?(view, "#workbench-filter-chip-work-state", "Any issue or PR state") - assert has_element?(view, "#workbench-filter-chip-freshness-window", "Any freshness") - assert has_element?(view, "#workbench-filter-chip-sort-order", "Repository name (A-Z)") - assert has_element?(view, "#workbench-filter-project option[value='all'][selected]") - assert has_element?(view, "#workbench-filter-work-state option[value='all'][selected]") - assert has_element?(view, "#workbench-filter-freshness-window option[value='any'][selected]") - - assert has_element?( - view, - "#workbench-filter-sort-order option[value='project_name_asc'][selected]" - ) - - assert has_element?(view, "#workbench-project-name-owner-repo-one", "owner/repo-one") - assert has_element?(view, "#workbench-project-name-owner-repo-two", "owner/repo-two") - end - - defp create_semantic_workspace_path!(module_name) do - workspace_path = - Path.join( - System.tmp_dir!(), - "jido_code_workbench_semantic_#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(Path.join(workspace_path, "lib")) - - File.write!( - Path.join(workspace_path, "mix.exs"), - """ - defmodule WorkbenchSemantic.MixProject do - use Mix.Project - - def project do - [app: :workbench_semantic, version: "0.1.0", elixir: "~> 1.18", deps: []] - end - end - """ - ) - - rewrite_semantic_workspace_module!(workspace_path, module_name) - - on_exit(fn -> File.rm_rf!(workspace_path) end) - workspace_path - end - - defp rewrite_semantic_workspace_module!(workspace_path, module_name) do - module_basename = - module_name - |> String.split(".") - |> List.last() - |> Macro.underscore() - - File.write!( - Path.join(workspace_path, "lib/#{module_basename}.ex"), - """ - defmodule #{module_name} do - def greet(name) when is_binary(name), do: "hello " <> name - end - """ - ) - - workspace_path - |> Path.join("lib/*.ex") - |> Path.wildcard() - |> Enum.reject(&String.ends_with?(&1, "#{module_basename}.ex")) - |> Enum.each(&File.rm!/1) - end - - defp seed_memory_graph!(managed_repo_id, workspace_path, revision) do - assert {:ok, _refresh_result} = - AgentWorkspace.refresh_memory_graph(managed_repo_id, workspace_path, revision: revision) - - session_id = "workbench-session-#{System.unique_integer([:positive])}" - - assert {:ok, _session_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - CaptureEnvelope.work_session( - session_id: session_id, - actor_id: "system:workbench-memory", - workflow: :plan, - work_item_id: "work-32", - goal: "Seed workbench memory inspection" - ), - graph_name: MemoryGraph.workflow_provenance_graph_name(), - revision: revision - ) - - assert {:ok, _memory_result} = - AgentWorkspace.record_memory_graph( - managed_repo_id, - workspace_path, - DurableMemoryEnvelope.known_issue( - session_id: session_id, - actor_id: "system:workbench-memory", - workflow: :review, - work_item_id: "work-32", - content: "Repository memory should stay visible from workbench.", - revision: revision, - anchors: %{module_name: "ExampleWorkspace"}, - classification: %{ - source: "workbench_test", - reason: "Workbench memory hints need a durable repository issue." - } - ), - revision: revision - ) - end - - defp assert_eventually(assertion_fun, attempts \\ 20) - - defp assert_eventually(assertion_fun, attempts) when attempts > 0 do - if assertion_fun.() do - :ok - else - receive do - after - 25 -> - assert_eventually(assertion_fun, attempts - 1) - end - end - end - - defp assert_eventually(_assertion_fun, 0) do - flunk("expected condition to become true") - end - - defp apply_workbench_filters(view, overrides) do - filter_params = Map.merge(default_workbench_filter_params(), overrides) - - view - |> element("#workbench-filters-form") - |> render_change(%{"filters" => filter_params}) - end - - defp default_workbench_filter_params do - %{ - "project_id" => "all", - "work_state" => "all", - "freshness_window" => "any", - "sort_order" => "project_name_asc" - } - end - - defp assert_project_row_order(view, row_ids) when is_list(row_ids) do - row_ids - |> Enum.with_index(1) - |> Enum.each(fn {row_id, index} -> - assert has_element?( - view, - "#workbench-project-rows tr:nth-child(#{index}) #workbench-project-name-#{row_id}" - ) - end) - end -end diff --git a/test/jido_code_web/live/workflows_live_test.exs b/test/jido_code_web/live/workflows_live_test.exs deleted file mode 100644 index 60a508c1..00000000 --- a/test/jido_code_web/live/workflows_live_test.exs +++ /dev/null @@ -1,205 +0,0 @@ -defmodule JidoCodeWeb.WorkflowsLiveTest do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.liveview_remains_product_host_shell - # covers: architecture.frontend_stack.adoption_is_incremental_per_surface - use JidoCodeWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - setup do - original_workflow_definition_loader = - Application.get_env(:jido_code, :workflow_manual_definition_loader, :__missing__) - - original_workflow_manual_run_launcher = - Application.get_env(:jido_code, :workflow_manual_run_launcher, :__missing__) - - on_exit(fn -> - restore_env(:workflow_manual_definition_loader, original_workflow_definition_loader) - restore_env(:workflow_manual_run_launcher, original_workflow_manual_run_launcher) - end) - - :ok - end - - test "creates manual workflow runs with project trigger and input metadata plus run detail route", - %{conn: _conn} do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-workflows", - github_full_name: "owner/repo-workflows", - default_branch: "main", - workspace_settings: %{} - }) - - repo_id = repo.id - - launch_requests = start_supervised!({Agent, fn -> [] end}) - - Application.put_env(:jido_code, :workflow_manual_definition_loader, fn -> - {:ok, [workflow_definition("implement_task", 4)]} - end) - - Application.put_env(:jido_code, :workflow_manual_run_launcher, fn kickoff_request -> - Agent.update(launch_requests, fn requests -> [kickoff_request | requests] end) - {:ok, %{run_id: "run-manual-123"}} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workflows", on_error: :warn) - - assert has_element?(view, "#workflows-manual-run-form") - assert has_element?(view, "#workflows-project-id") - assert has_element?(view, "#workflows-workflow-name") - assert has_element?(view, "#workflows-input-task-summary") - assert has_element?(view, "#workflows-start-run") - assert has_element?(view, "#workflows-runs-empty-state") - - view - |> form("#workflows-manual-run-form", - run: %{ - repo_id: repo_id, - workflow_name: "implement_task", - task_summary: "Ship onboarding copy updates with tests." - } - ) - |> render_submit() - - assert has_element?(view, "#workflows-run-feedback-status", "Run creation succeeded.") - assert has_element?(view, "#workflows-run-feedback-run-id", "run-manual-123") - assert has_element?(view, "#workflows-run-feedback-workflow-version", "v4") - - assert has_element?( - view, - "#workflows-run-feedback-run-link[href='/repos/#{repo_id}/runs/run-manual-123']" - ) - - assert has_element?(view, "#workflows-run-id-run-manual-123", "run-manual-123") - assert has_element?(view, "#workflows-run-workflow-run-manual-123", "implement_task") - assert has_element?(view, "#workflows-run-workflow-version-run-manual-123", "v4") - assert has_element?(view, "#workflows-run-project-run-manual-123", "repo-workflows") - assert has_element?(view, "#workflows-run-trigger-run-manual-123", "/workflows") - - assert has_element?( - view, - "#workflows-run-detail-link-run-manual-123[href='/repos/#{repo_id}/runs/run-manual-123']" - ) - - refute has_element?(view, "#workflows-runs-empty-state") - - recorded_requests = launch_requests |> Agent.get(&Enum.reverse(&1)) - - assert [ - %{ - workflow_name: "implement_task", - workflow_version: 4, - repo_id: ^repo_id, - project_id: ^repo_id, - repository_defaults: %{ - default_branch: "main", - github_full_name: "owner/repo-workflows" - }, - project_defaults: %{ - default_branch: "main", - github_full_name: "owner/repo-workflows" - }, - trigger: %{ - source: "workflows", - mode: "manual", - source_row: %{ - route: "/workflows", - repo_id: ^repo_id, - project_id: ^repo_id, - workflow_name: "implement_task", - workflow_version: 4 - } - }, - inputs: %{"task_summary" => "Ship onboarding copy updates with tests."}, - input_metadata: %{ - "task_summary" => %{required: true, source: "manual_workflows_ui"} - }, - initiating_actor: %{id: actor_id} - } - ] = recorded_requests - - assert is_binary(actor_id) - refute actor_id == "" - assert Map.has_key?(hd(recorded_requests).initiating_actor, :email) - end - - test "missing required inputs return typed validation errors and do not create partial runs", %{ - conn: _conn - } do - register_owner("owner@example.com", "owner-password-123") - - {authed_conn, _session_token} = - authenticate_owner_conn("owner@example.com", "owner-password-123") - - %{managed_repo: repo} = - provision_managed_repo!(%{ - name: "repo-validation", - github_full_name: "owner/repo-validation", - default_branch: "main", - workspace_settings: %{} - }) - - launcher_invocations = start_supervised!({Agent, fn -> 0 end}) - - Application.put_env(:jido_code, :workflow_manual_run_launcher, fn _kickoff_request -> - Agent.update(launcher_invocations, &(&1 + 1)) - {:ok, %{run_id: "unexpected-run"}} - end) - - {:ok, view, _html} = live(recycle(authed_conn), ~p"/workflows", on_error: :warn) - - view - |> form("#workflows-manual-run-form", - run: %{ - repo_id: repo.id, - workflow_name: "implement_task", - task_summary: "" - } - ) - |> render_submit() - - assert has_element?(view, "#workflows-run-feedback-status", "Run creation failed.") - - assert has_element?( - view, - "#workflows-run-feedback-error-type", - "workflow_run_validation_failed" - ) - - assert has_element?( - view, - "#workflows-run-feedback-error-detail", - "required inputs are missing" - ) - - assert has_element?(view, "#workflows-run-feedback-field-errors", "task_summary") - assert has_element?(view, "#workflows-run-feedback-field-errors", "required") - - refute has_element?(view, "#workflows-run-feedback-run-id") - assert has_element?(view, "#workflows-runs-empty-state") - assert Agent.get(launcher_invocations, & &1) == 0 - end - - defp workflow_definition(name, version) do - %{ - name: name, - version: version, - label: "Implement task", - description: "Plan and implement an operator-scoped coding task.", - required_inputs: [ - %{ - name: :task_summary, - label: "Task summary", - placeholder: "Describe the task this run should implement." - } - ] - } - end -end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index b8f7d60e..afa5d7d6 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -47,7 +47,6 @@ defmodule JidoCodeWeb.ConnCase do import Plug.Conn import Phoenix.ConnTest import JidoCodeWeb.ConnCase - import JidoCodeWeb.LiveVueCase end end diff --git a/test/support/live_vue_boundary_live.ex b/test/support/live_vue_boundary_live.ex deleted file mode 100644 index 4e5c186e..00000000 --- a/test/support/live_vue_boundary_live.ex +++ /dev/null @@ -1,71 +0,0 @@ -defmodule JidoCodeWeb.TestSupport.LiveVueBoundaryLive do - # covers: package.jido_code.version_controlled_quality_surfaces - use JidoCodeWeb, :live_view - - alias Phoenix.LiveView.JS - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:failure, nil) - |> assign(:status, "idle") - |> assign(:task_form, to_form(%{"title" => ""}, as: :task)) - |> allow_upload(:artifact, accept: ~w(.txt), max_entries: 1) - |> stream(:items, [%{id: "seed-1", label: "Initial item"}]) - - {:ok, socket} - end - - @impl true - def render(assigns) do - ~H""" -
- - - - - <.vue_surface - id="boundary-probe" - component="BoundaryProbe" - socket={@socket} - props={ - %{ - failure: @failure, - status: @status, - taskForm: @task_form, - uploadConfig: @uploads.artifact - } - } - streams={%{items: @streams.items}} - events={ - %{ - "request-sync" => JS.push("request_sync", value: %{origin: "vue"}), - "simulate-failure" => "simulate_failure" - } - } - /> -
- """ - end - - @impl true - def handle_event("request_sync", _params, socket) do - socket = - socket - |> assign(:failure, nil) - |> assign(:status, "synced") - |> stream_insert(:items, %{id: "sync-1", label: "Server refresh"}) - - {:noreply, socket} - end - - @impl true - def handle_event("simulate_failure", _params, socket) do - {:noreply, assign(socket, :failure, %{kind: "validation", message: "Need a title"})} - end -end diff --git a/test/support/live_vue_case.ex b/test/support/live_vue_case.ex deleted file mode 100644 index 57f44983..00000000 --- a/test/support/live_vue_case.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule JidoCodeWeb.LiveVueCase do - # covers: package.jido_code.version_controlled_quality_surfaces - # covers: architecture.frontend_stack.testing_keeps_liveview_and_adds_live_vue_aware_helpers - @moduledoc false - - import ExUnit.Assertions - - alias Phoenix.LiveView.JS - - def vue(view_or_html, opts \\ []) do - LiveVue.Test.get_vue(view_or_html, opts) - end - - def assert_vue_component(view_or_html, component, opts \\ []) do - vue = vue(view_or_html, opts) - assert vue.component == component - vue - end - - def assert_vue_prop(view_or_html, key, expected, opts \\ []) do - vue = vue(view_or_html, opts) - assert Map.fetch!(vue.props, to_string(key)) == expected - vue - end - - def assert_vue_handler(view_or_html, emit, expected, opts \\ []) do - vue = vue(view_or_html, opts) - - expected = - case expected do - %JS{} = js -> js - event_name when is_binary(event_name) -> JS.push(event_name) - end - - expected = normalize_js(expected) - - assert normalize_js(Map.fetch!(vue.handlers, to_string(emit))) == expected - vue - end - - defp normalize_js(%JS{ops: ops} = js) do - %{js | ops: normalize_term(ops)} - end - - defp normalize_term(term) when is_list(term), do: Enum.map(term, &normalize_term/1) - - defp normalize_term(term) when is_map(term) do - Map.new(term, fn {key, value} -> {normalize_key(key), normalize_term(value)} end) - end - - defp normalize_term(term), do: term - - defp normalize_key(key) when is_atom(key), do: Atom.to_string(key) - defp normalize_key(key), do: key -end diff --git a/test/support/live_vue_host_shell_live.ex b/test/support/live_vue_host_shell_live.ex deleted file mode 100644 index 987f2030..00000000 --- a/test/support/live_vue_host_shell_live.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule JidoCodeWeb.TestSupport.LiveVueHostShellLive do - use JidoCodeWeb, :live_view - - @impl true - def mount(_params, _session, socket) do - {:ok, assign(socket, :label, "LiveVue ready")} - end - - @impl true - def render(assigns) do - ~H""" -
- <.vue - id="shell-probe" - label={@label} - v-component="ShellProbe" - v-socket={@socket} - /> -
- """ - end -end diff --git a/tsconfig.json b/tsconfig.json index bbdd5cb6..bb5219c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,21 +2,27 @@ "compilerOptions": { "allowJs": true, "baseUrl": ".", - "lib": ["ES2015", "DOM"], + "esModuleInterop": true, + "isolatedModules": true, + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable", "ES2022"], "module": "ESNext", - "moduleResolution": "bundler", + "moduleResolution": "Bundler", "noEmit": true, - "skipLibCheck": true, "paths": { - "*": [ "./deps/*", "node_modules/*" ] + "@/*": ["./assets/*"] }, + "resolveJsonModule": true, + "skipLibCheck": true, "strict": true, - "types": [ "vite/client" ] + "target": "ES2022", + "types": ["vite/client"] }, "include": [ - "./assets/js/**/*", - "./assets/vue/**/*", - "./lib/jido_code_web/**/*" + "assets/js/**/*", + "assets/vue/**/*", + "assets/vue/**/*.vue", + "assets/vue/env.d.ts" ], "exclude": [ "node_modules"