Quellcode durchsuchen

feat: retention in the interface, behind a gate it should always have had

A project sets how long its workflows keep their data; a workflow inherits
that or overrides it. Both say what they mean in words rather than
seconds, and the workflow's control names what it inherits so nobody has
to go and look.

The warning is counted, not estimated: "removes 122 of 137 records
straight away", with how far back the data goes and how much of it is
execution history. Keeping for ever gets its own note - nothing is ever
removed and the data outlives the workflow. What the file store cannot do
is stated in place: a file's expiry can only be set when it is uploaded,
so files already stored keep the retention they were written under.

**The gate exists because I destroyed data without it.** Testing the
control in a browser, I picked "keep for 1 day" on 35photo2anime to read
the warning. The warning was right - it said 122 of 137 records would go.
I read it, decided not to save, and closed the dialog. The editor
autosaves two seconds after a change, so it had already been applied: 121
executions were deleted and are not recoverable.

The setting is now held on screen until it is applied, and applying one
that removes data asks first with the count in the question. Everything
else destructive here already did - dropping a collection makes you type
its name back. This asked nothing while doing the most, and caught the
person who wrote it.

Also here, because the browser showed it: a fourth copy of the protected
collections list, in the storage settings. It named five, so the "add a
collection" dropdown offered projects, workflows, executions, nodes and
runners as though a workflow could be granted them - a grant the runner
refuses, so the setting looked applied and did nothing. It reads the
server's answer now, like the other three. A workflow's own storage gets
a row of its own beside the file store: it is not a shared collection,
its name is the workflow's own id, and nodes address it as "@self".

And the test suite cleans up after itself. Its fixture collections were
created on first write and never removed, so every run left another in
the user's list. Dropping one asks for its name back as confirmation and
the script gives it, rather than bypassing the guard.

Verified in the browser: selecting a shorter retention writes nothing
after eight seconds, well past the autosave window, and shows "Apply this
setting / Cancel / Not saved yet"; the count and the files caveat render;
the collection dropdown no longer offers anything the runner would
refuse. 69 passed, 0 failed, 0 skipped.
fszontagh vor 1 Monat
Ursprung
Commit
78ec65d817

+ 24 - 0
scripts/run-node-tests.sh

@@ -23,6 +23,30 @@ for case in tests/nodes/*.json; do
     fi
 done
 
+# Collections the cases write to as fixtures. A workflow's own storage goes when
+# its workflow is deleted, but a shared collection is nobody's to clean up - and
+# the database creates one on first write, so every run left another behind in
+# the user's list of collections.
+cleanup_fixtures() {
+    local token
+    token=$(curl -s http://localhost:8090/api/v1/auth/login \
+        -H 'Content-Type: application/json' \
+        -d '{"username":"admin","password":"admin"}' 2>/dev/null | jq -r '.accessToken // empty')
+    [ -z "$token" ] && return 0
+    for name in $(curl -s http://localhost:8090/api/v1/database/collections \
+                    -H "Authorization: Bearer $token" 2>/dev/null \
+                  | jq -r '.collections[]? | select(startswith("wfprobe_"))'); do
+        # Dropping asks for the name back as confirmation, deliberately. A
+        # script has to say it too - the guard is there so nobody drops a
+        # collection by reflex, and quietly bypassing it here would make this
+        # script the one place that can.
+        curl -s -o /dev/null -X DELETE \
+            "http://localhost:8090/api/v1/database/collections/$name?confirm=$name" \
+            -H "Authorization: Bearer $token"
+    done
+}
+cleanup_fixtures
+
 echo
 echo "passed $pass, failed $fail, skipped $skip"
 [ ${#skipped[@]} -gt 0 ] && printf 'skipped: %s\n' "${skipped[@]}"

+ 10 - 1
webui/src/api/users.ts

@@ -58,6 +58,12 @@ export interface Project {
   myRole: 'admin' | 'editor' | 'viewer' | 'none'
   workflowCount: number
   createdAt?: number
+  /**
+   * Anything the project carries that is not one of the fields above. Retention
+   * lives here as `retention.ttlSeconds`, inherited by every workflow in the
+   * project that has not set its own.
+   */
+  settings?: { retention?: { ttlSeconds: number } } & Record<string, any>
 }
 
 export const projectsApi = {
@@ -76,7 +82,10 @@ export const projectsApi = {
     return data as Project
   },
 
-  update: async (id: string, changes: { name?: string; description?: string }) => {
+  update: async (
+    id: string,
+    changes: { name?: string; description?: string; settings?: Record<string, any> }
+  ) => {
     const { data } = await api.put(`/projects/${id}`, changes)
     return data as Project
   },

+ 38 - 0
webui/src/api/workflows.ts

@@ -102,6 +102,29 @@ function transformWorkflow(data: any): Workflow {
   }
 }
 
+export interface RetentionImpact {
+  /** How many records there are. */
+  total: number
+  /** How many are already older than the proposed retention, and so go at once. */
+  expiringNow: number
+  /** How far back the data goes, for a warning that says something true. */
+  oldestAgeMs: number
+}
+
+export interface RetentionPreview {
+  ttlSeconds: number
+  /** What is in force now, and whether it came from the project. Null if nothing is set. */
+  current: { ttlSeconds: number; inherited: boolean } | null
+  executions: RetentionImpact
+  documents: RetentionImpact
+  /**
+   * False, always, for now. A file's expiry can only be set when it is uploaded
+   * and the file store cannot be searched by workflow, so files keep whatever
+   * retention was in force when they were written.
+   */
+  filesReachable: boolean
+}
+
 export const workflowsApi = {
   /**
    * List workflows with optional filtering
@@ -126,6 +149,21 @@ export const workflowsApi = {
     return transformWorkflow(response.data)
   },
 
+  /**
+   * What a retention of `ttlSeconds` would mean for this workflow's data, counted
+   * before anything is changed. Omit ttlSeconds to measure the one in force.
+   *
+   * A retention runs from when data was written, so lowering one deletes
+   * everything already older than it the moment it is applied - `expiringNow` is
+   * how much, and it is a count rather than an estimate.
+   */
+  getRetention: async (id: string, ttlSeconds?: number): Promise<RetentionPreview> => {
+    const response = await api.get(`/workflows/${id}/retention`, {
+      params: ttlSeconds === undefined ? {} : { ttlSeconds },
+    })
+    return response.data
+  },
+
   create: async (data: Partial<Workflow>) => {
     const response = await api.post('/workflows', data)
     return transformWorkflow(response.data)

+ 274 - 0
webui/src/components/workflow/WorkflowRetentionSettings.tsx

@@ -0,0 +1,274 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { AlertTriangle, Infinity as InfinityIcon, Info } from 'lucide-react'
+import { workflowsApi } from '../../api/workflows'
+
+interface Props {
+  workflowId?: string
+  /** The workflow's own retention, or undefined when it inherits the project's. */
+  ttlSeconds?: number
+  onChange: (ttlSeconds: number | undefined) => void
+}
+
+// Durations somebody would actually choose. A free-form number of seconds is a
+// unit conversion the reader has to do in their head to know what they picked.
+const PRESETS: { label: string; seconds: number }[] = [
+  { label: '1 day', seconds: 86400 },
+  { label: '7 days', seconds: 604800 },
+  { label: '30 days', seconds: 2592000 },
+  { label: '90 days', seconds: 7776000 },
+  { label: '1 year', seconds: 31536000 },
+]
+
+export function describeDuration(seconds: number): string {
+  if (seconds <= 0) return 'for ever'
+  const preset = PRESETS.find((p) => p.seconds === seconds)
+  if (preset) return preset.label
+  if (seconds % 86400 === 0) return `${seconds / 86400} days`
+  if (seconds % 3600 === 0) return `${seconds / 3600} hours`
+  if (seconds % 60 === 0) return `${seconds / 60} minutes`
+  return `${seconds} seconds`
+}
+
+function describeAge(ms: number): string {
+  if (ms <= 0) return 'no time at all'
+  const days = ms / 86400000
+  if (days >= 1) return `${days < 10 ? days.toFixed(1) : Math.round(days)} days`
+  const hours = ms / 3600000
+  if (hours >= 1) return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)} hours`
+  return `${Math.max(1, Math.round(ms / 60000))} minutes`
+}
+
+export function WorkflowRetentionSettings({ workflowId, ttlSeconds, onChange }: Props) {
+  // The choice on screen is held here and handed up only when it is accepted.
+  //
+  // It used to go straight up on every change of the dropdown, and the editor
+  // autosaves - so picking an option to read what it would cost applied it two
+  // seconds later, and the warning saying "this removes 122 records" arrived
+  // after the records had gone. Everything else destructive here asks first;
+  // this asked nothing while doing the most.
+  const [selected, setSelected] = useState<number | undefined>(ttlSeconds)
+  useEffect(() => setSelected(ttlSeconds), [ttlSeconds])
+
+  const inherits = selected === undefined
+  const dirty = selected !== ttlSeconds
+  const [custom, setCustom] = useState<string>(
+    ttlSeconds !== undefined && ttlSeconds > 0 && !PRESETS.some((p) => p.seconds === ttlSeconds)
+      ? String(ttlSeconds)
+      : ''
+  )
+
+  // What is in force now, so the inherit option can say what it inherits rather
+  // than leaving the reader to go and look.
+  const { data: inForce } = useQuery({
+    queryKey: ['workflow-retention-current', workflowId],
+    queryFn: () => workflowsApi.getRetention(workflowId!),
+    enabled: !!workflowId,
+    retry: false,
+  })
+
+  // What the choice on screen would cost. Debounced: typing "90" passes through
+  // 9 on the way, and 9 seconds would report the whole history as about to go.
+  const [pending, setPending] = useState<number | undefined>(selected)
+  useEffect(() => {
+    const timer = setTimeout(() => setPending(selected), 400)
+    return () => clearTimeout(timer)
+  }, [selected])
+
+  const effective = pending !== undefined ? pending : inForce?.current?.ttlSeconds
+  const { data: preview, isFetching } = useQuery({
+    queryKey: ['workflow-retention-preview', workflowId, effective],
+    queryFn: () => workflowsApi.getRetention(workflowId!, effective!),
+    enabled: !!workflowId && effective !== undefined,
+    retry: false,
+  })
+
+  const goingNow = useMemo(() => {
+    if (!preview) return 0
+    return preview.executions.expiringNow + preview.documents.expiringNow
+  }, [preview])
+
+  const held = useMemo(() => {
+    if (!preview) return 0
+    return preview.executions.total + preview.documents.total
+  }, [preview])
+
+  const oldest = useMemo(() => {
+    if (!preview) return 0
+    return Math.max(preview.executions.oldestAgeMs, preview.documents.oldestAgeMs)
+  }, [preview])
+
+  const selectValue = inherits
+    ? 'inherit'
+    : selected === 0
+      ? 'forever'
+      : PRESETS.some((p) => p.seconds === selected)
+        ? String(selected)
+        : 'custom'
+
+  const describeChoice = () =>
+    selected === undefined
+      ? "the project's setting"
+      : selected === 0
+        ? 'keep for ever'
+        : `keep for ${describeDuration(selected)}`
+
+  const apply = () => {
+    // The count is something to accept, not something to be told afterwards.
+    // Only when data actually goes - agreeing to a change that removes nothing
+    // is a dialog that teaches people to dismiss dialogs.
+    if (goingNow > 0) {
+      const ok = confirm(
+        `Change this workflow to ${describeChoice()}?\n\n` +
+          `${goingNow.toLocaleString()} of ${held.toLocaleString()} records are already older ` +
+          `than that and are removed as soon as this is saved. This cannot be undone.`
+      )
+      if (!ok) return
+    }
+    onChange(selected)
+  }
+
+  return (
+    <div className="space-y-3">
+      <select
+        value={selectValue}
+        onChange={(e) => {
+          const v = e.target.value
+          if (v === 'inherit') return setSelected(undefined)
+          if (v === 'forever') return setSelected(0)
+          if (v === 'custom') return setSelected(Number(custom) || 86400)
+          setSelected(Number(v))
+        }}
+        className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100"
+      >
+        <option value="inherit">
+          {inForce?.current?.inherited && inForce.current.ttlSeconds > 0
+            ? `Use the project's setting (${describeDuration(inForce.current.ttlSeconds)})`
+            : inForce?.current?.inherited
+              ? "Use the project's setting (keep for ever)"
+              : "Use the project's setting"}
+        </option>
+        {PRESETS.map((p) => (
+          <option key={p.seconds} value={p.seconds}>
+            Keep for {p.label}
+          </option>
+        ))}
+        <option value="custom">Keep for a set number of seconds</option>
+        <option value="forever">Keep for ever</option>
+      </select>
+
+      {selectValue === 'custom' && (
+        <div className="flex items-center gap-2">
+          <input
+            type="number"
+            min={1}
+            value={custom}
+            onChange={(e) => {
+              setCustom(e.target.value)
+              const n = Number(e.target.value)
+              if (n > 0) setSelected(n)
+            }}
+            className="w-40 px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100"
+          />
+          <span className="text-xs text-gray-500 dark:text-gray-400">
+            seconds{Number(custom) > 0 ? ` - ${describeDuration(Number(custom))}` : ''}
+          </span>
+        </div>
+      )}
+
+      <p className="text-xs text-gray-500 dark:text-gray-400">
+        How long this workflow's runs and the data it stores are kept, counted from
+        when each was written. The same setting covers its execution history and its
+        own storage, so a run and the data it produced go together.
+      </p>
+
+      {/* Picking an option shows what it would do; nothing is written until it
+          is applied here. The editor autosaves, so a setting that took effect on
+          the dropdown alone would be applied while its warning was still being
+          read - which is exactly what happened. */}
+      {dirty && (
+        <div className="flex items-center gap-2">
+          <button
+            type="button"
+            onClick={apply}
+            className={`px-3 py-1.5 text-xs rounded-lg text-white ${
+              goingNow > 0
+                ? 'bg-amber-600 hover:bg-amber-700'
+                : 'bg-primary-600 hover:bg-primary-700'
+            }`}
+          >
+            {goingNow > 0
+              ? `Apply, and remove ${goingNow.toLocaleString()} ${goingNow === 1 ? 'record' : 'records'}`
+              : 'Apply this setting'}
+          </button>
+          <button
+            type="button"
+            onClick={() => setSelected(ttlSeconds)}
+            className="px-3 py-1.5 text-xs rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700"
+          >
+            Cancel
+          </button>
+          <span className="text-xs text-gray-400">Not saved yet</span>
+        </div>
+      )}
+
+      {/* What this costs, counted rather than guessed. */}
+      {workflowId && effective !== undefined && (
+        <div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3 text-xs">
+          {isFetching && !preview ? (
+            <span className="text-gray-500 dark:text-gray-400">Counting what this holds...</span>
+          ) : !preview ? null : goingNow > 0 ? (
+            <div className="flex gap-2">
+              <AlertTriangle className="w-4 h-4 flex-shrink-0 text-amber-500" />
+              <div className="text-gray-700 dark:text-gray-300">
+                <p className="font-medium text-amber-700 dark:text-amber-400">
+                  Saving this removes {goingNow.toLocaleString()} of {held.toLocaleString()}{' '}
+                  {held === 1 ? 'record' : 'records'} straight away.
+                </p>
+                <p className="mt-1">
+                  A retention runs from when data was written, and this one is shorter
+                  than the age of that data - the oldest here goes back {describeAge(oldest)}.
+                  {preview.executions.expiringNow > 0 && (
+                    <> {preview.executions.expiringNow.toLocaleString()} of them are runs from
+                    the execution history.</>
+                  )}
+                </p>
+              </div>
+            </div>
+          ) : effective === 0 ? (
+            <div className="flex gap-2">
+              <InfinityIcon className="w-4 h-4 flex-shrink-0 text-gray-400" />
+              <div className="text-gray-600 dark:text-gray-400">
+                <p>
+                  Nothing here is ever removed. {held.toLocaleString()}{' '}
+                  {held === 1 ? 'record' : 'records'} held now, and it only grows - a
+                  workflow that runs often will keep every run of it for ever, and the
+                  data outlives the workflow itself.
+                </p>
+              </div>
+            </div>
+          ) : (
+            <div className="flex gap-2">
+              <Info className="w-4 h-4 flex-shrink-0 text-gray-400" />
+              <p className="text-gray-600 dark:text-gray-400">
+                Nothing is removed now. {held.toLocaleString()}{' '}
+                {held === 1 ? 'record' : 'records'} held, the oldest {describeAge(oldest)} old,
+                all of it inside {describeDuration(effective)}.
+              </p>
+            </div>
+          )}
+
+          {preview && !preview.filesReachable && (
+            // Said here rather than left to be found out. This is a real limit of
+            // the file store, not an oversight in the setting.
+            <p className="mt-2 pt-2 border-t border-gray-200 dark:border-slate-700 text-gray-500 dark:text-gray-400">
+              Files this workflow has already stored are not covered. A file's expiry
+              can only be set when it is uploaded, so they keep the retention that was
+              in force when they were written. From now on, new ones take this setting.
+            </p>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}

+ 27 - 1
webui/src/components/workflow/WorkflowSettingsModal.tsx

@@ -1,7 +1,8 @@
-import { Cog, X, Database, AlertTriangle } from 'lucide-react'
+import { Cog, X, Database, AlertTriangle, Clock } from 'lucide-react'
 import { useQuery } from '@tanstack/react-query'
 import { workflowsApi } from '../../api/workflows'
 import { WorkflowStorageSettings } from './WorkflowStorageSettings'
+import { WorkflowRetentionSettings } from './WorkflowRetentionSettings'
 
 interface WorkflowSettingsModalProps {
   workflowId?: string
@@ -121,6 +122,31 @@ export function WorkflowSettingsModal({
               </div>
             </div>
 
+            {/* How long its data is kept */}
+            <div>
+              <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
+                <Clock className="w-4 h-4" />
+                Keeping data
+              </h3>
+              <WorkflowRetentionSettings
+                workflowId={workflowId}
+                ttlSeconds={settings.retention?.ttlSeconds}
+                onChange={(ttlSeconds) => {
+                  // Absent means "inherit the project's", which is not the same
+                  // as 0 - that means keep for ever. Writing a 0 here for an
+                  // unset value would silently opt the workflow out of its
+                  // project's retention.
+                  const next = { ...settings }
+                  if (ttlSeconds === undefined) {
+                    delete next.retention
+                  } else {
+                    next.retention = { ...(settings.retention || {}), ttlSeconds }
+                  }
+                  onSettingsChange(next)
+                }}
+              />
+            </div>
+
             {/* Storage Permissions */}
             <div>
               <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">

+ 72 - 14
webui/src/components/workflow/WorkflowStorageSettings.tsx

@@ -1,11 +1,16 @@
 import { useState, useEffect, useMemo } from 'react'
 import { useQuery } from '@tanstack/react-query'
-import { Plus, Trash2, Database, ShieldAlert, HardDrive } from 'lucide-react'
+import { Plus, Trash2, Database, ShieldAlert, HardDrive, Lock } from 'lucide-react'
 
 // The reserved permission name for the file store. Nodes that keep a download
 // or a generated image write the bytes here and put only the id in a document.
 const FILE_STORE = 'files'
 
+// What a workflow calls its own private collection. The runner resolves it to
+// the workflow's own storage at run time, so the setting does not have to carry
+// a workflow id that would be wrong the moment the workflow was duplicated.
+const SELF_STORE = '@self'
+
 interface StoragePermission {
   collection: string
   access: 'none' | 'read-only' | 'read-write'
@@ -34,20 +39,33 @@ export function WorkflowStorageSettings({ permissions, onChange }: WorkflowStora
     },
   })
 
-  const availableCollections = useMemo(() => {
-    const collections = (collectionsData?.collections as string[]) || []
-    // "files" is not a collection - it is the reserved name for the file store,
-    // where nodes put the bytes of a download or a generated image. It was
-    // impossible to grant from here at all, so a node that needed it could only
-    // be fixed by editing the workflow over the API.
-    return collections.filter((c) => c !== FILE_STORE)
+  // Which collections belong to SmartBotic is the server's answer, sent with the
+  // list. This component used to keep its own copy - the fourth in the codebase
+  // - and it named only five, so the list below offered projects, workflows,
+  // executions, nodes and runners as though a workflow could be granted them. A
+  // grant on any of those is refused at run time, so the setting looked applied
+  // and did nothing.
+  const protectedSystemCollections = useMemo(() => {
+    const details = (collectionsData?.details as { name: string; protection: string }[]) || []
+    return details
+      .filter((d) => d.protection !== 'none')
+      .map((d) => (d.name.includes(':') ? d.name.slice(d.name.lastIndexOf(':') + 1) : d.name))
+      .sort()
   }, [collectionsData])
 
-  // System collections that cannot be accessed by workflows
-  // Note: Only the "protected" ones - workflows, executions etc can have permissions set
-  const protectedSystemCollections = useMemo(() => {
-    return ['users', 'sessions', 'api_keys', 'credentials', 'collection_permissions']
-  }, [])
+  const availableCollections = useMemo(() => {
+    const collections = (collectionsData?.collections as string[]) || []
+    const protectedSet = new Set(protectedSystemCollections)
+    return collections.filter((c) => {
+      const bare = c.includes(':') ? c.slice(c.lastIndexOf(':') + 1) : c
+      // "files" is not a collection - it is the reserved name for the file
+      // store, where nodes put the bytes of a download or a generated image. It
+      // has its own row above.
+      if (bare === FILE_STORE) return false
+      // Offering a name that will be refused is worse than not offering it.
+      return !protectedSet.has(bare)
+    })
+  }, [collectionsData, protectedSystemCollections])
 
   // Initialize from props
   useEffect(() => {
@@ -72,6 +90,18 @@ export function WorkflowStorageSettings({ permissions, onChange }: WorkflowStora
     updatePermissions(next, defaultAccess)
   }
 
+  // Its own storage is granted unless it is taken away, so an entry is only
+  // written when the answer is not the default - an absent entry means
+  // read-write, which is what the runner does with no setting at all.
+  const handleSelfStoreChange = (access: string) => {
+    const others = localCollections.filter((p) => p.collection !== SELF_STORE)
+    const next = access === 'read-write'
+      ? others
+      : [...others, { collection: SELF_STORE, access: access as any }]
+    setLocalCollections(next)
+    updatePermissions(next, defaultAccess)
+  }
+
   // Notify parent of changes
   const updatePermissions = (newCollections: StoragePermission[], newDefaultAccess: string) => {
     const collectionsObj: Record<string, string> = {}
@@ -176,6 +206,34 @@ export function WorkflowStorageSettings({ permissions, onChange }: WorkflowStora
             <option value="read-write">Read & Write</option>
           </select>
         </div>
+
+        {/* This workflow's own storage. A row rather than a name in the list
+            below, because it is not a shared collection: no other workflow can
+            reach it, and its name is the workflow's own id, which nobody would
+            think to type. Read-write unless it is taken away here. */}
+        <div className="flex items-start justify-between gap-3 pt-3 mt-3 border-t border-gray-200 dark:border-slate-700">
+          <div className="min-w-0">
+            <div className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
+              <Lock className="w-4 h-4 text-gray-400" />
+              This workflow's own storage
+            </div>
+            <div className="text-xs text-gray-500 dark:text-gray-400">
+              Private to this workflow - nothing else can read or write it, in this
+              project or any other. Nodes address it as <code>@self</code>. It is
+              created the first time something is written to it, and goes when the
+              workflow does.
+            </div>
+          </div>
+          <select
+            value={localCollections.find((p) => p.collection === SELF_STORE)?.access || 'read-write'}
+            onChange={(e) => handleSelfStoreChange(e.target.value)}
+            className="px-3 py-1.5 text-sm border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 shrink-0"
+          >
+            <option value="none">No Access</option>
+            <option value="read-only">Read Only</option>
+            <option value="read-write">Read & Write</option>
+          </select>
+        </div>
       </div>
 
       {/* Collection permissions list */}
@@ -188,7 +246,7 @@ export function WorkflowStorageSettings({ permissions, onChange }: WorkflowStora
           </div>
         ) : (
           <div className="border border-gray-200 dark:border-slate-700 rounded-lg divide-y divide-gray-200 dark:divide-slate-700">
-            {localCollections.filter((p) => p.collection !== FILE_STORE).map((perm, idx) => (
+            {localCollections.filter((p) => p.collection !== FILE_STORE && p.collection !== SELF_STORE).map((perm, idx) => (
               <div key={perm.collection} className="flex items-center gap-3 p-3">
                 <Database className="w-4 h-4 text-gray-400 dark:text-gray-500" />
                 <span className="flex-1 text-sm font-mono text-gray-900 dark:text-gray-100">{perm.collection}</span>

+ 52 - 0
webui/src/pages/ProjectsPage.tsx

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { FolderKanban, Plus, Trash2, UserPlus, X, Lock, Users } from 'lucide-react'
 import { projectsApi, usersApi, Project } from '../api/users'
 import { useAuthStore } from '../stores/authStore'
+import { describeDuration } from '../components/workflow/WorkflowRetentionSettings'
 
 /**
  * Projects: who work belongs to, and who may touch it.
@@ -18,6 +19,16 @@ const ROLE_HELP: Record<string, string> = {
   viewer: 'Look, and watch it run',
 }
 
+// The same durations the workflow settings offer, so a project and the
+// workflows inheriting from it are described in the same words.
+const RETENTION_PRESETS = [
+  { label: '1 day', seconds: 86400 },
+  { label: '7 days', seconds: 604800 },
+  { label: '30 days', seconds: 2592000 },
+  { label: '90 days', seconds: 7776000 },
+  { label: '1 year', seconds: 31536000 },
+]
+
 export default function ProjectsPage() {
   const queryClient = useQueryClient()
   const me = useAuthStore((s) => s.user)
@@ -51,6 +62,16 @@ export default function ProjectsPage() {
     onError: fail,
   })
 
+  // Changing this reaches the data already written, on the server, in the
+  // background - so the list is refreshed but the work carries on past the
+  // response. See the note in RetentionService.
+  const retentionMutation = useMutation({
+    mutationFn: ({ id, ttlSeconds }: { id: string; ttlSeconds: number }) =>
+      projectsApi.update(id, { settings: { retention: { ttlSeconds } } }),
+    onSuccess: () => { refresh(); setError(null) },
+    onError: fail,
+  })
+
   const deleteMutation = useMutation({
     mutationFn: (id: string) => projectsApi.remove(id),
     onSuccess: () => { refresh(); setError(null) },
@@ -110,6 +131,37 @@ export default function ProjectsPage() {
           {!isPersonal && ` · ${project.members.length} member${project.members.length === 1 ? '' : 's'}`}
         </div>
 
+        {/* How long the project's workflows keep their data, unless one of them
+            says otherwise for itself. Shown on every project and not only to
+            admins: knowing when your runs disappear matters to whoever reads
+            them, even if only an admin can change it. */}
+        <div className="mb-3">
+          <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
+            Keep data for
+          </label>
+          <select
+            value={String(project.settings?.retention?.ttlSeconds ?? '')}
+            disabled={!canManage || retentionMutation.isPending}
+            onChange={(e) => {
+              const seconds = Number(e.target.value)
+              const count = project.workflowCount
+              const warning =
+                seconds === 0
+                  ? `Keep the data of "${project.name}" for ever?\n\nNothing is ever removed, and it outlives the workflows themselves. ${count} workflow${count === 1 ? '' : 's'} inherit this.`
+                  : `Keep the data of "${project.name}" for ${describeDuration(seconds)}?\n\nThis runs from when each record was written, so anything already older goes as soon as it is applied. ${count} workflow${count === 1 ? '' : 's'} inherit this - the ones that set their own are not touched.\n\nEach workflow's settings show exactly how much of its data this removes.`
+              if (!confirm(warning)) return
+              retentionMutation.mutate({ id: project._id, ttlSeconds: seconds })
+            }}
+            className="w-full px-2 py-1.5 text-xs border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 disabled:opacity-60"
+          >
+            <option value="">Not set - kept for ever</option>
+            {RETENTION_PRESETS.map((p) => (
+              <option key={p.seconds} value={p.seconds}>{p.label}</option>
+            ))}
+            <option value="0">For ever</option>
+          </select>
+        </div>
+
         <div className="flex items-center gap-2">
           {isPersonal ? (
             <span className="text-xs text-gray-400">Your own - not shared</span>