feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door: - services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one. - coverage: the archive scan now carries every path, and the refresh runs detection fail-open. - inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected. - MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms. - REST: /api/platforms (admin writes) and /api/projects/<id>/platforms. - UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
/** Project inception (milestone 297): what a project was decided to inherit. */
|
/** Project inception (milestone 297): what a project was decided to inherit. */
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
|
import type { ProjectPlatform } from "@/api/platforms";
|
||||||
|
|
||||||
export interface InceptionChoices {
|
export interface InceptionChoices {
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
seed_systems: boolean;
|
seed_systems: boolean;
|
||||||
|
/** Platform slugs (milestone 463). null = not answered; a list is the
|
||||||
|
* whole answer — members left out of it are recorded as "not this".
|
||||||
|
* Absent on records decided before the question existed. */
|
||||||
|
platforms?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InceptionRecord {
|
export interface InceptionRecord {
|
||||||
@@ -17,16 +22,24 @@ export interface InceptionDefaults {
|
|||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
design_systems: { id: number; title: string }[];
|
design_systems: { id: number; title: string }[];
|
||||||
systems: number;
|
systems: number;
|
||||||
|
platforms: { slug: string; name: string }[];
|
||||||
|
/** What detection (or an earlier answer) already says about the project. */
|
||||||
|
project_platforms: ProjectPlatform[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InceptionDecision {
|
export interface InceptionDecision {
|
||||||
project_id: number;
|
project_id: number;
|
||||||
inception: InceptionRecord;
|
inception: InceptionRecord;
|
||||||
effects: { design_system_id: number | null; systems_seeded: string[] };
|
effects: {
|
||||||
|
design_system_id: number | null;
|
||||||
|
systems_seeded: string[];
|
||||||
|
/** The project's answers after the decision; null when left unstated. */
|
||||||
|
platforms: ProjectPlatform[] | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emptyChoices = (): InceptionChoices => ({
|
export const emptyChoices = (): InceptionChoices => ({
|
||||||
design_system_id: null, seed_systems: false,
|
design_system_id: null, seed_systems: false, platforms: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchInceptionDefaults = (projectId: number) =>
|
export const fetchInceptionDefaults = (projectId: number) =>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Platforms — what a project is built on or ships as (milestone 463).
|
||||||
|
*
|
||||||
|
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
|
||||||
|
* project's answer per platform is `declared` (a person said so), `detected`
|
||||||
|
* (a marker file in a bound repo said so) or `rejected` (a person said no —
|
||||||
|
* kept, so the next refresh cannot detect it back). Only declared and
|
||||||
|
* detected make a project a member, and membership is what decides which
|
||||||
|
* family ideas reach it.
|
||||||
|
*/
|
||||||
|
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
|
||||||
|
|
||||||
|
export interface Platform {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
/** The match key, and the name every door takes (stable across restores). */
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
/** Repo-relative globs. A bare name matches a basename anywhere; one with
|
||||||
|
* a slash matches the whole path. Empty = declare-only. */
|
||||||
|
markers: string[];
|
||||||
|
order_index: number;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatformState = "declared" | "detected" | "rejected";
|
||||||
|
/** What a person may set. `null` withdraws the answer. */
|
||||||
|
export type SettablePlatformState = "declared" | "rejected" | null;
|
||||||
|
|
||||||
|
export interface ProjectPlatform {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
state: PlatformState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEMBER_STATES: PlatformState[] = ["declared", "detected"];
|
||||||
|
|
||||||
|
export async function listPlatforms(): Promise<Platform[]> {
|
||||||
|
const data = await apiGet<{ platforms: Platform[] }>("/api/platforms");
|
||||||
|
return data.platforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin only. A name that reduces to an existing slug answers 409. */
|
||||||
|
export async function createPlatform(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
markers?: string[];
|
||||||
|
}): Promise<Platform> {
|
||||||
|
return apiPost("/api/platforms", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlatform(
|
||||||
|
id: number,
|
||||||
|
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
|
||||||
|
): Promise<Platform> {
|
||||||
|
return apiPatch(`/api/platforms/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectPlatforms(projectId: number): Promise<ProjectPlatform[]> {
|
||||||
|
const data = await apiGet<{ project_platforms: ProjectPlatform[] }>(
|
||||||
|
`/api/projects/${projectId}/platforms`,
|
||||||
|
);
|
||||||
|
return data.project_platforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the slugs named change; the update applies whole or not at all. */
|
||||||
|
export async function setProjectPlatforms(
|
||||||
|
projectId: number,
|
||||||
|
platforms: Record<string, SettablePlatformState>,
|
||||||
|
): Promise<ProjectPlatform[]> {
|
||||||
|
const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
|
||||||
|
`/api/projects/${projectId}/platforms`, { platforms },
|
||||||
|
);
|
||||||
|
return data.project_platforms;
|
||||||
|
}
|
||||||
@@ -6,10 +6,16 @@
|
|||||||
* second step and only emits the choices (the project does not exist yet);
|
* second step and only emits the choices (the project does not exist yet);
|
||||||
* mode="decide" sits on ProjectView for an undecided project, loads that
|
* mode="decide" sits on ProjectView for an undecided project, loads that
|
||||||
* project's current defaults, and records the decision itself.
|
* project's current defaults, and records the decision itself.
|
||||||
|
*
|
||||||
|
* Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
|
||||||
|
* the list the whole answer; in decide mode the boxes start from what
|
||||||
|
* detection already found, so recording confirms it.
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { fetchDesignSystems } from "@/api/designSystems";
|
import { fetchDesignSystems } from "@/api/designSystems";
|
||||||
|
import { MEMBER_STATES } from "@/api/platforms";
|
||||||
|
import { usePlatformsStore } from "@/stores/platforms";
|
||||||
import {
|
import {
|
||||||
decideInception, emptyChoices, fetchInceptionDefaults,
|
decideInception, emptyChoices, fetchInceptionDefaults,
|
||||||
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
|
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
|
||||||
@@ -29,6 +35,9 @@ const emit = defineEmits<{
|
|||||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
const systemsCount = ref(0);
|
const systemsCount = ref(0);
|
||||||
|
const platforms = ref<{ slug: string; name: string }[]>([]);
|
||||||
|
const detected = ref<Set<string>>(new Set());
|
||||||
|
const platformsStore = usePlatformsStore();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
@@ -46,11 +55,25 @@ async function load() {
|
|||||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||||
designSystems.value = d.design_systems;
|
designSystems.value = d.design_systems;
|
||||||
systemsCount.value = d.systems;
|
systemsCount.value = d.systems;
|
||||||
|
platforms.value = d.platforms;
|
||||||
|
const members = d.project_platforms
|
||||||
|
.filter((p) => MEMBER_STATES.includes(p.state))
|
||||||
|
.map((p) => p.slug);
|
||||||
|
detected.value = new Set(
|
||||||
|
d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug),
|
||||||
|
);
|
||||||
// Start from what stands today, so "record" without changes keeps it.
|
// Start from what stands today, so "record" without changes keeps it.
|
||||||
local.value = { design_system_id: d.design_system_id, seed_systems: false };
|
local.value = {
|
||||||
|
design_system_id: d.design_system_id,
|
||||||
|
seed_systems: false,
|
||||||
|
platforms: members.length ? members : null,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const ds = await fetchDesignSystems();
|
const [ds, catalog] = await Promise.all([
|
||||||
|
fetchDesignSystems(), platformsStore.fetchCatalog(),
|
||||||
|
]);
|
||||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||||
|
platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name }));
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
|
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
|
||||||
@@ -61,6 +84,22 @@ async function load() {
|
|||||||
|
|
||||||
const nothingToDecide = computed(() => !designSystems.value.length);
|
const nothingToDecide = computed(() => !designSystems.value.length);
|
||||||
|
|
||||||
|
function isChecked(slug: string): boolean {
|
||||||
|
return (local.value.platforms ?? []).includes(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlatform(slug: string, on: boolean) {
|
||||||
|
const current = new Set(local.value.platforms ?? []);
|
||||||
|
if (on) current.add(slug);
|
||||||
|
else current.delete(slug);
|
||||||
|
local.value.platforms = [...current].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to "not answered" — distinct from an empty list, which says "none". */
|
||||||
|
function clearPlatforms() {
|
||||||
|
local.value.platforms = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function record() {
|
async function record() {
|
||||||
if (!props.projectId) return;
|
if (!props.projectId) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
@@ -105,6 +144,31 @@ onMounted(load);
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="platforms.length" class="inception-group">
|
||||||
|
<h4>Platforms</h4>
|
||||||
|
<p class="inception-muted">
|
||||||
|
What it is built on or ships as. Ideas the family has proven for a
|
||||||
|
platform reach every project that is one.
|
||||||
|
<template v-if="local.platforms === null"> Not answered yet.</template>
|
||||||
|
<template v-else>
|
||||||
|
Anything left unticked is recorded as "not this project".
|
||||||
|
<button type="button" class="btn-ghost btn-sm" @click="clearPlatforms">Leave unanswered</button>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<div class="inception-platforms">
|
||||||
|
<label v-for="p in platforms" :key="p.slug" class="inception-choice">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChecked(p.slug)"
|
||||||
|
@change="togglePlatform(p.slug, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ p.name }}
|
||||||
|
<em v-if="detected.has(p.slug)" class="inception-muted"> — detected</em>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p v-if="nothingToDecide" class="inception-muted">
|
<p v-if="nothingToDecide" class="inception-muted">
|
||||||
No design systems on this install yet — recording still settles the question.
|
No design systems on this install yet — recording still settles the question.
|
||||||
</p>
|
</p>
|
||||||
@@ -132,6 +196,11 @@ onMounted(load);
|
|||||||
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
|
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
|
||||||
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
|
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
|
||||||
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
|
.inception-platforms {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||||
|
gap: 0 1rem;
|
||||||
|
}
|
||||||
.inception-select {
|
.inception-select {
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--fs-border-color);
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A project's place in the family (milestone 463): which platforms it is.
|
||||||
|
*
|
||||||
|
* Membership is what makes "does this family idea reach this project?" a
|
||||||
|
* lookup rather than a judgment — an idea is for some platforms, and it
|
||||||
|
* reaches every project that is one of them. So the answers here matter, and
|
||||||
|
* each kind of answer is shown as what it is:
|
||||||
|
*
|
||||||
|
* - Yes — a person said so.
|
||||||
|
* - Detected — a marker file in a bound repo said so. Detection only adds,
|
||||||
|
* and only where nobody has answered; it never overrides a Yes or a No.
|
||||||
|
* - No — a person said this project is NOT that platform. Kept as a row, so
|
||||||
|
* the next coverage refresh cannot detect it straight back.
|
||||||
|
* - No answer — detection may decide on the next refresh.
|
||||||
|
*
|
||||||
|
* Each change is saved as it is made: one platform, one request, applied
|
||||||
|
* whole or refused whole.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import {
|
||||||
|
fetchProjectPlatforms, setProjectPlatforms,
|
||||||
|
type PlatformState, type ProjectPlatform, type SettablePlatformState,
|
||||||
|
} from "@/api/platforms";
|
||||||
|
import { usePlatformsStore } from "@/stores/platforms";
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: number; canWrite: boolean }>();
|
||||||
|
|
||||||
|
const platformsStore = usePlatformsStore();
|
||||||
|
const answers = ref<ProjectPlatform[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
const saving = ref<string | null>(null);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const LABELS: Record<PlatformState, string> = {
|
||||||
|
declared: "Yes",
|
||||||
|
detected: "Detected",
|
||||||
|
rejected: "No",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
failed.value = false;
|
||||||
|
try {
|
||||||
|
const [, rows] = await Promise.all([
|
||||||
|
platformsStore.fetchCatalog(),
|
||||||
|
fetchProjectPlatforms(props.projectId),
|
||||||
|
]);
|
||||||
|
answers.value = rows;
|
||||||
|
} catch {
|
||||||
|
// Said out loud: "couldn't load" must not read as "no platforms".
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateBySlug = computed(() => {
|
||||||
|
const out: Record<string, PlatformState> = {};
|
||||||
|
for (const a of answers.value) out[a.slug] = a.state;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = computed(() =>
|
||||||
|
platformsStore.catalog.map((p) => ({
|
||||||
|
...p,
|
||||||
|
state: stateBySlug.value[p.slug] ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberCount = computed(
|
||||||
|
() => answers.value.filter((a) => a.state !== "rejected").length,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function answer(slug: string, value: string) {
|
||||||
|
const state: SettablePlatformState = value === "" ? null : (value as SettablePlatformState);
|
||||||
|
saving.value = slug;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
answers.value = await setProjectPlatforms(props.projectId, { [slug]: state });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = apiErrorMessage(e, "Could not save that answer");
|
||||||
|
} finally {
|
||||||
|
saving.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => props.projectId, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pft">
|
||||||
|
<header class="pft-head">
|
||||||
|
<h3 class="pft-title">Platforms</h3>
|
||||||
|
<p class="pft-muted">
|
||||||
|
What this project is built on or ships as. Ideas the family has proven
|
||||||
|
for a platform reach every project that is one, and each project
|
||||||
|
answers them — adopts, varies with a reason, or owes the work.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="loading" class="pft-muted">Loading…</p>
|
||||||
|
|
||||||
|
<div v-else-if="failed" class="pft-note">
|
||||||
|
<strong>The platforms couldn't load.</strong>
|
||||||
|
<p>Nothing is known either way — this is a failure, not an empty answer.</p>
|
||||||
|
<button class="btn-secondary btn-sm" @click="load">Try again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="!rows.length" class="pft-muted">
|
||||||
|
This install has no platforms in its catalog yet. An administrator adds
|
||||||
|
them in Settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="pft-summary">
|
||||||
|
<template v-if="memberCount">
|
||||||
|
A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Not a member of any platform yet — bind a repo and refresh coverage
|
||||||
|
to detect them, or answer below.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||||
|
<ul class="pft-list">
|
||||||
|
<li v-for="p in rows" :key="p.id" class="pft-row">
|
||||||
|
<div class="pft-name">
|
||||||
|
<span>{{ p.name }}</span>
|
||||||
|
<span v-if="p.state" :class="['pft-tag', `pft-${p.state}`]">{{ LABELS[p.state] }}</span>
|
||||||
|
<span v-if="p.description" class="pft-desc">{{ p.description }}</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-if="canWrite"
|
||||||
|
class="pft-select"
|
||||||
|
:value="p.state === 'detected' ? 'detected' : (p.state ?? '')"
|
||||||
|
:disabled="saving === p.slug"
|
||||||
|
:aria-label="`Is this project ${p.name}?`"
|
||||||
|
@change="answer(p.slug, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-if="p.state === 'detected'" value="detected" disabled>Detected</option>
|
||||||
|
<option value="declared">Yes</option>
|
||||||
|
<option value="rejected">No</option>
|
||||||
|
<option value="">No answer</option>
|
||||||
|
</select>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pft { padding: 0.25rem 0; }
|
||||||
|
.pft-head { margin-bottom: 1rem; }
|
||||||
|
.pft-title { margin: 0 0 0.35rem; font-size: 1rem; }
|
||||||
|
.pft-muted { color: var(--fs-text-tertiary); font-size: 0.875rem; margin: 0; }
|
||||||
|
.pft-summary { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 0.75rem; }
|
||||||
|
.pft-note {
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
.pft-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
|
||||||
|
.pft-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.pft-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.pft-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; min-width: 0; }
|
||||||
|
.pft-desc { flex-basis: 100%; color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||||
|
.pft-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
.pft-declared { color: var(--fs-text-primary); }
|
||||||
|
.pft-rejected { color: var(--fs-text-tertiary); text-decoration: line-through; }
|
||||||
|
.pft-select {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
background: var(--fs-surface-page);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pft-row { flex-direction: column; align-items: stretch; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import * as api from "@/api/platforms";
|
||||||
|
import type { Platform } from "@/api/platforms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global platform catalog (milestone 463). Shared by every project, so
|
||||||
|
* it is fetched once per session — as the canonical-area catalog is.
|
||||||
|
*/
|
||||||
|
export const usePlatformsStore = defineStore("platforms", () => {
|
||||||
|
const catalog = ref<Platform[]>([]);
|
||||||
|
const loaded = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function fetchCatalog(force = false) {
|
||||||
|
if (loaded.value && !force) return catalog.value;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
catalog.value = await api.listPlatforms();
|
||||||
|
loaded.value = true;
|
||||||
|
} catch {
|
||||||
|
// The catalog is a vocabulary for a form; an empty one degrades the
|
||||||
|
// form rather than failing the screen it sits on.
|
||||||
|
catalog.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
return catalog.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEntry(data: { name: string; description?: string; markers?: string[] }) {
|
||||||
|
const entry = await api.createPlatform(data);
|
||||||
|
catalog.value.push(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEntry(
|
||||||
|
id: number,
|
||||||
|
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
|
||||||
|
) {
|
||||||
|
const entry = await api.updatePlatform(id, data);
|
||||||
|
const idx = catalog.value.findIndex((p) => p.id === id);
|
||||||
|
if (idx >= 0) catalog.value[idx] = entry;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry };
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue";
|
|||||||
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||||
import type { TaskKind } from "@/types/note";
|
import type { TaskKind } from "@/types/note";
|
||||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||||
|
import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue";
|
||||||
|
import { canWriteRecord } from "@/utils/permission";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
import InceptionCard from "@/components/InceptionCard.vue";
|
import InceptionCard from "@/components/InceptionCard.vue";
|
||||||
@@ -125,7 +127,7 @@ async function confirmStartPlanning() {
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
|
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
|
||||||
|
|
||||||
const tasks = ref<NoteItem[]>([]);
|
const tasks = ref<NoteItem[]>([]);
|
||||||
const notes = ref<NoteItem[]>([]);
|
const notes = ref<NoteItem[]>([]);
|
||||||
@@ -747,6 +749,10 @@ async function confirmDelete() {
|
|||||||
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
||||||
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
|
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
|
||||||
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
|
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
|
||||||
|
<!-- What was said AT inception; the live answers are on the Family tab. -->
|
||||||
|
<template v-if="project.inception.choices.platforms?.length">
|
||||||
|
· platforms {{ project.inception.choices.platforms.join(", ") }}
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Summary stat chips -->
|
<!-- Summary stat chips -->
|
||||||
@@ -945,6 +951,9 @@ async function confirmDelete() {
|
|||||||
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
|
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
|
||||||
Design
|
Design
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['tab-btn', { active: activeTab === 'family' }]" @click="activeTab = 'family'">
|
||||||
|
Family
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tasks tab — milestone-grouped kanban -->
|
<!-- Tasks tab — milestone-grouped kanban -->
|
||||||
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
|
|||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:design-system-id="project.design_system_id ?? null"
|
:design-system-id="project.design_system_id ?? null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Family tab (milestone 463): which platforms this project is. -->
|
||||||
|
<ProjectFamilyTab
|
||||||
|
v-if="activeTab === 'family'"
|
||||||
|
:project-id="projectId"
|
||||||
|
:can-write="canWriteRecord(project.permission)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
|
import { usePlatformsStore } from "@/stores/platforms";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
@@ -71,6 +72,75 @@ async function saveArea() {
|
|||||||
savingArea.value = false;
|
savingArea.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── Platforms (milestone 463) ───────────────────────────────────────────
|
||||||
|
// The global list of what a project can be built on or ship as. Same split
|
||||||
|
// as the areas: admin-only to write, readable by everyone. Markers are the
|
||||||
|
// repo-relative globs that let a coverage refresh DETECT a platform; they
|
||||||
|
// are edited one per line, since a glob may itself contain a comma.
|
||||||
|
const platformsStore = usePlatformsStore();
|
||||||
|
const newPlatformName = ref("");
|
||||||
|
const newPlatformDescription = ref("");
|
||||||
|
const newPlatformMarkers = ref("");
|
||||||
|
const creatingPlatform = ref(false);
|
||||||
|
const editingPlatformId = ref<number | null>(null);
|
||||||
|
const editPlatformName = ref("");
|
||||||
|
const editPlatformDescription = ref("");
|
||||||
|
const editPlatformMarkers = ref("");
|
||||||
|
const savingPlatform = ref(false);
|
||||||
|
|
||||||
|
function markerLines(text: string): string[] {
|
||||||
|
return text.split("\n").map((m) => m.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlatform() {
|
||||||
|
const name = newPlatformName.value.trim();
|
||||||
|
if (!name || creatingPlatform.value) return;
|
||||||
|
creatingPlatform.value = true;
|
||||||
|
try {
|
||||||
|
await platformsStore.createEntry({
|
||||||
|
name,
|
||||||
|
description: newPlatformDescription.value.trim() || undefined,
|
||||||
|
markers: markerLines(newPlatformMarkers.value),
|
||||||
|
});
|
||||||
|
newPlatformName.value = "";
|
||||||
|
newPlatformDescription.value = "";
|
||||||
|
newPlatformMarkers.value = "";
|
||||||
|
toastStore.show("Platform added");
|
||||||
|
} catch (e) {
|
||||||
|
// A 409 names the existing platform the new name reduces to.
|
||||||
|
toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
|
||||||
|
} finally {
|
||||||
|
creatingPlatform.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
|
||||||
|
editingPlatformId.value = id;
|
||||||
|
editPlatformName.value = name;
|
||||||
|
editPlatformDescription.value = description ?? "";
|
||||||
|
editPlatformMarkers.value = markers.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlatform() {
|
||||||
|
const id = editingPlatformId.value;
|
||||||
|
const name = editPlatformName.value.trim();
|
||||||
|
if (id == null || !name || savingPlatform.value) return;
|
||||||
|
savingPlatform.value = true;
|
||||||
|
try {
|
||||||
|
await platformsStore.updateEntry(id, {
|
||||||
|
name,
|
||||||
|
description: editPlatformDescription.value.trim(),
|
||||||
|
markers: markerLines(editPlatformMarkers.value),
|
||||||
|
});
|
||||||
|
editingPlatformId.value = null;
|
||||||
|
toastStore.show("Platform updated");
|
||||||
|
} catch (e) {
|
||||||
|
toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
|
||||||
|
} finally {
|
||||||
|
savingPlatform.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userTimezone = ref("");
|
const userTimezone = ref("");
|
||||||
const savingTimezone = ref(false);
|
const savingTimezone = ref(false);
|
||||||
const timezoneSaved = ref(false);
|
const timezoneSaved = ref(false);
|
||||||
@@ -479,7 +549,7 @@ async function copyCommit() {
|
|||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
|
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
|
||||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||||
|
|
||||||
@@ -489,6 +559,7 @@ function _loadTabContent(tab: string) {
|
|||||||
else if (tab === "logs") loadLogsPanel();
|
else if (tab === "logs") loadLogsPanel();
|
||||||
else if (tab === "groups") loadGroupsPanel();
|
else if (tab === "groups") loadGroupsPanel();
|
||||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||||
|
else if (tab === "platforms") platformsStore.fetchCatalog(true);
|
||||||
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||||||
}
|
}
|
||||||
if (tab === "apikeys") { fetchApiKeys(); }
|
if (tab === "apikeys") { fetchApiKeys(); }
|
||||||
@@ -1611,7 +1682,7 @@ async function deleteUser(userId: number) {
|
|||||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||||
<div class="sidebar-group-label">Admin</div>
|
<div class="sidebar-group-label">Admin</div>
|
||||||
<button
|
<button
|
||||||
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
|
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
@@ -3179,6 +3250,99 @@ async function deleteUser(userId: number) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Platforms ── -->
|
||||||
|
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Platforms</h2>
|
||||||
|
<p class="field-hint">
|
||||||
|
What a project can be built on or ship as. A project's platforms decide which family
|
||||||
|
ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
|
||||||
|
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
|
||||||
|
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
|
||||||
|
markers is never detected — projects declare it themselves.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="area-admin-list">
|
||||||
|
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
|
||||||
|
<template v-if="editingPlatformId === entry.id">
|
||||||
|
<form class="area-admin-form" @submit.prevent="savePlatform">
|
||||||
|
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
|
||||||
|
<textarea
|
||||||
|
v-model="editPlatformDescription"
|
||||||
|
class="fs-input"
|
||||||
|
rows="2"
|
||||||
|
aria-label="Platform description"
|
||||||
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="editPlatformMarkers"
|
||||||
|
class="fs-input area-admin-markers"
|
||||||
|
rows="3"
|
||||||
|
aria-label="Detection markers, one per line"
|
||||||
|
></textarea>
|
||||||
|
<div class="area-admin-actions">
|
||||||
|
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
|
||||||
|
{{ savingPlatform ? "Saving…" : "Save" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="area-admin-body">
|
||||||
|
<div class="area-admin-name-row">
|
||||||
|
<span class="area-admin-name">{{ entry.name }}</span>
|
||||||
|
<code class="area-admin-slug" title="The match key, and the name tools and backups use.">{{ entry.slug }}</code>
|
||||||
|
</div>
|
||||||
|
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||||||
|
<p class="area-admin-desc">
|
||||||
|
<template v-if="entry.markers.length">
|
||||||
|
Detected by
|
||||||
|
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
|
||||||
|
</template>
|
||||||
|
<template v-else>Declare-only — never detected.</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn-ghost btn-compact"
|
||||||
|
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
|
||||||
|
>Edit</button>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
|
||||||
|
No platforms yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
|
||||||
|
<input
|
||||||
|
v-model="newPlatformName"
|
||||||
|
class="fs-input"
|
||||||
|
placeholder="New platform name (e.g. Flutter app)"
|
||||||
|
aria-label="New platform name"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="newPlatformDescription"
|
||||||
|
class="fs-input"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What makes a project this platform? One sentence."
|
||||||
|
aria-label="New platform description"
|
||||||
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="newPlatformMarkers"
|
||||||
|
class="fs-input area-admin-markers"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
|
||||||
|
aria-label="New platform markers, one per line"
|
||||||
|
></textarea>
|
||||||
|
<div class="area-admin-actions">
|
||||||
|
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
|
||||||
|
{{ creatingPlatform ? "Adding…" : "Add platform" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) {
|
|||||||
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||||||
.area-admin-create { margin-top: var(--fs-space-4); }
|
.area-admin-create { margin-top: var(--fs-space-4); }
|
||||||
.area-admin-actions { display: flex; gap: 0.4rem; }
|
.area-admin-actions { display: flex; gap: 0.4rem; }
|
||||||
|
.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
|
||||||
|
.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
|
||||||
|
|
||||||
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
|
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
|
||||||
the operator is reviewing what was done, and the reason is the part worth
|
the operator is reviewing what was done, and the reason is the part worth
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from scribe.routes.trash import trash_bp
|
|||||||
from scribe.routes.dashboard import dashboard_bp
|
from scribe.routes.dashboard import dashboard_bp
|
||||||
from scribe.routes.systems import systems_bp
|
from scribe.routes.systems import systems_bp
|
||||||
from scribe.routes.canonical_systems import canonical_systems_bp
|
from scribe.routes.canonical_systems import canonical_systems_bp
|
||||||
|
from scribe.routes.platforms import platforms_bp
|
||||||
from scribe.routes.lessons import lessons_bp
|
from scribe.routes.lessons import lessons_bp
|
||||||
from scribe.routes.snippets import snippets_bp
|
from scribe.routes.snippets import snippets_bp
|
||||||
from scribe.routes.webhooks import webhooks_bp
|
from scribe.routes.webhooks import webhooks_bp
|
||||||
@@ -101,6 +102,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(systems_bp)
|
app.register_blueprint(systems_bp)
|
||||||
app.register_blueprint(canonical_systems_bp)
|
app.register_blueprint(canonical_systems_bp)
|
||||||
|
app.register_blueprint(platforms_bp)
|
||||||
app.register_blueprint(snippets_bp)
|
app.register_blueprint(snippets_bp)
|
||||||
app.register_blueprint(webhooks_bp)
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
# retrieval_telemetry's reason, and needed by a read key so that a line
|
# retrieval_telemetry's reason, and needed by a read key so that a line
|
||||||
# naming a moment can be understood by whoever was shown it.
|
# naming a moment can be understood by whoever was shown it.
|
||||||
"list_moments",
|
"list_moments",
|
||||||
|
# The platform catalog and a project's answers (milestone 463). A pure
|
||||||
|
# read; set_project_platforms is the write.
|
||||||
|
"list_platforms",
|
||||||
# The pass over the corpus and its queue (milestone 458 step 7): which
|
# The pass over the corpus and its queue (milestone 458 step 7): which
|
||||||
# rules are unjudged and which proposals wait. Reads of the caller's own
|
# rules are unjudged and which proposals wait. Reads of the caller's own
|
||||||
# rules, as list_rules is.
|
# rules, as list_rules is.
|
||||||
@@ -183,6 +186,7 @@ _WRITE_TOOLS = frozenset({
|
|||||||
# projects, Systems, repos
|
# projects, Systems, repos
|
||||||
"create_project", "update_project", "delete_project", "decide_project_inception",
|
"create_project", "update_project", "delete_project", "decide_project_inception",
|
||||||
"create_system", "update_system", "delete_system", "map_system_to_canonical",
|
"create_system", "update_system", "delete_system", "map_system_to_canonical",
|
||||||
|
"set_project_platforms",
|
||||||
"bind_repo", "unbind_repo",
|
"bind_repo", "unbind_repo",
|
||||||
# snippets, processes, the shape ledger
|
# snippets, processes, the shape ledger
|
||||||
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from `mcp.server.build_mcp_server`.
|
|||||||
"""
|
"""
|
||||||
from scribe.mcp.tools import (
|
from scribe.mcp.tools import (
|
||||||
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
|
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
|
||||||
moments, retrieval_review, retrieval_tuning,
|
moments, platforms, retrieval_review, retrieval_tuning,
|
||||||
wide_net,
|
wide_net,
|
||||||
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
|
||||||
)
|
)
|
||||||
@@ -24,6 +24,7 @@ def register_all(mcp) -> None:
|
|||||||
projects.register(mcp)
|
projects.register(mcp)
|
||||||
milestones.register(mcp)
|
milestones.register(mcp)
|
||||||
systems.register(mcp)
|
systems.register(mcp)
|
||||||
|
platforms.register(mcp)
|
||||||
design_systems.register(mcp)
|
design_systems.register(mcp)
|
||||||
tags.register(mcp)
|
tags.register(mcp)
|
||||||
recent.register(mcp)
|
recent.register(mcp)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Platform MCP tools — what a project is built on or ships as (milestone 463).
|
||||||
|
|
||||||
|
Thin wrappers over services/platforms.py. The catalog is global; a project's
|
||||||
|
platforms decide which family ideas reach it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
|
|
||||||
|
|
||||||
|
async def list_platforms(project_id: int = 0) -> dict:
|
||||||
|
"""The GLOBAL platform catalog — runtimes, delivery channels and
|
||||||
|
toolchains a project can be built on or ship as (Android app, container
|
||||||
|
image, Go, …) — and, with a project_id, that project's answer for each.
|
||||||
|
|
||||||
|
A project's platforms decide which family ideas reach it: an idea is for
|
||||||
|
some platforms, and every project that is one of them answers it. Pass
|
||||||
|
slugs from here to set_project_platforms and decide_project_inception.
|
||||||
|
|
||||||
|
Each platform's `markers` are the file patterns that let the coverage
|
||||||
|
refresh DETECT it in a bound repo. A project's `state` per platform is
|
||||||
|
`declared` (a person said so), `detected` (a marker said so) or `rejected`
|
||||||
|
(a person said no — kept so detection cannot add it back). Only declared
|
||||||
|
and detected are membership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: also return this project's answers. 0 = catalog only.
|
||||||
|
"""
|
||||||
|
catalog = await platforms_svc.list_platforms()
|
||||||
|
out: dict = {"platforms": [p.to_dict() for p in catalog]}
|
||||||
|
if project_id:
|
||||||
|
rows = await platforms_svc.project_platforms(current_user_id(), project_id)
|
||||||
|
if rows is None:
|
||||||
|
raise ValueError(f"project {project_id} not found")
|
||||||
|
out["project_platforms"] = rows
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_platforms(
|
||||||
|
project_id: int,
|
||||||
|
declared: list[str] | None = None,
|
||||||
|
rejected: list[str] | None = None,
|
||||||
|
withdrawn: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Say which platforms a project is — or is not. Only the platforms named
|
||||||
|
change; everything else is left exactly as it is.
|
||||||
|
|
||||||
|
Use it when the operator corrects what detection found, or when a project
|
||||||
|
starts or stops shipping something (it gains an Android client; it drops
|
||||||
|
its container image). At project creation, decide_project_inception's
|
||||||
|
`platforms` is the place instead — it records the answer with the rest of
|
||||||
|
what the project inherits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: the project.
|
||||||
|
declared: slugs the project IS (list_platforms).
|
||||||
|
rejected: slugs it is NOT — recorded as a "no", so the coverage
|
||||||
|
refresh never detects it back.
|
||||||
|
withdrawn: slugs whose answer to drop entirely, so detection may
|
||||||
|
decide again on the next refresh.
|
||||||
|
"""
|
||||||
|
updates: dict[str, str | None] = {}
|
||||||
|
for slug in withdrawn or []:
|
||||||
|
updates[slug] = None
|
||||||
|
for slug in rejected or []:
|
||||||
|
updates[slug] = "rejected"
|
||||||
|
for slug in declared or []:
|
||||||
|
updates[slug] = "declared"
|
||||||
|
if not updates:
|
||||||
|
raise ValueError("name at least one platform to declare, reject or withdraw")
|
||||||
|
rows = await platforms_svc.set_project_platforms(current_user_id(), project_id, updates)
|
||||||
|
return {"project_id": project_id, "project_platforms": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp) -> None:
|
||||||
|
for fn in (list_platforms, set_project_platforms):
|
||||||
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
@@ -23,6 +23,7 @@ from scribe.services import design_systems as design_systems_svc
|
|||||||
from scribe.services import inception as inception_svc
|
from scribe.services import inception as inception_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
@@ -125,9 +126,15 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
create it with create_system rather than leaving the area unmodelled. Read
|
create it with create_system rather than leaving the area unmodelled. Read
|
||||||
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
|
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
|
||||||
|
|
||||||
|
`platforms` (milestone 463) is what the project is built on or ships as
|
||||||
|
— Android app, container image, Go, … — each with how it is known
|
||||||
|
(`declared` by a person, `detected` from a bound repo). They decide which
|
||||||
|
family ideas reach this project. An empty list on a project that plainly
|
||||||
|
ships something is worth correcting with set_project_platforms.
|
||||||
|
|
||||||
`inception` (milestone 297) appears ONLY when the project is yours and
|
`inception` (milestone 297) appears ONLY when the project is yours and
|
||||||
nobody has decided what it inherits: it carries the current defaults
|
nobody has decided what it inherits: it carries the current defaults
|
||||||
(design system, Systems), what to ask the
|
(design system, Systems, platforms), what to ask the
|
||||||
operator — once — and the decide_project_inception call that answers it;
|
operator — once — and the decide_project_inception call that answers it;
|
||||||
it repeats on every enter until a decision is recorded.
|
it repeats on every enter until a decision is recorded.
|
||||||
|
|
||||||
@@ -188,6 +195,9 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
# three days of the feature landing (#2546's audit). Untagged writes now
|
# three days of the feature landing (#2546's audit). Untagged writes now
|
||||||
# also ask with the vocabulary listed; this copy lets the first write tag.
|
# also ask with the vocabulary listed; this copy lets the first write tag.
|
||||||
systems = await systems_svc.list_systems(uid, project_id)
|
systems = await systems_svc.list_systems(uid, project_id)
|
||||||
|
platforms = platforms_svc.members(
|
||||||
|
await platforms_svc.project_platforms(uid, project_id) or []
|
||||||
|
)
|
||||||
|
|
||||||
# The arrival-moment half of the bootstrap ask (#2683): session start is
|
# The arrival-moment half of the bootstrap ask (#2683): session start is
|
||||||
# when the agent has just read the project map and is not yet deep in a
|
# when the agent has just read the project map and is not yet deep in a
|
||||||
@@ -258,6 +268,7 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
},
|
},
|
||||||
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
|
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
|
||||||
"systems": [{"id": s.id, "name": s.name} for s in systems],
|
"systems": [{"id": s.id, "name": s.name} for s in systems],
|
||||||
|
"platforms": platforms,
|
||||||
"design_system": design_system,
|
"design_system": design_system,
|
||||||
"milestone_summary": milestone_summary,
|
"milestone_summary": milestone_summary,
|
||||||
**rulebooks_svc.rules_payload(
|
**rulebooks_svc.rules_payload(
|
||||||
@@ -302,12 +313,15 @@ async def get_project(project_id: int) -> dict:
|
|||||||
rules (project_rules), and applicable_rules: the
|
rules (project_rules), and applicable_rules: the
|
||||||
global rules tagged to an area this project works in. Every other global
|
global rules tagged to an area this project works in. Every other global
|
||||||
rule applies too and arrives by retrieval when the work matches it.
|
rule applies too and arrives by retrieval when the work matches it.
|
||||||
|
`platforms` is every platform the project has an answer for, with its
|
||||||
|
state — rejected ones included, unlike enter_project's brief list.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
project = await projects_svc.get_project(uid, project_id)
|
project = await projects_svc.get_project(uid, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise ValueError(f"project {project_id} not found")
|
raise ValueError(f"project {project_id} not found")
|
||||||
data = project.to_dict()
|
data = project.to_dict()
|
||||||
|
data["platforms"] = await platforms_svc.project_platforms(uid, project_id) or []
|
||||||
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
|
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
|
||||||
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
|
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
|
||||||
applicable = await rulebooks_svc.get_applicable_rules(
|
applicable = await rulebooks_svc.get_applicable_rules(
|
||||||
@@ -317,17 +331,21 @@ async def get_project(project_id: int) -> dict:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _inception_choices(design_system_id, seed_systems) -> dict | None:
|
def _inception_choices(design_system_id, seed_systems, platforms=None) -> dict | None:
|
||||||
"""The tool args → an inception choices object, or None when no inception
|
"""The tool args → an inception choices object, or None when no inception
|
||||||
arg was given at all (a bare create stays undecided and enter_project
|
arg was given at all (a bare create stays undecided and enter_project
|
||||||
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
|
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
|
||||||
system."""
|
system. platforms: None = not stated (memberships untouched), a list =
|
||||||
if not design_system_id and seed_systems is None:
|
the whole answer."""
|
||||||
|
if not design_system_id and seed_systems is None and platforms is None:
|
||||||
return None
|
return None
|
||||||
return {
|
choices = {
|
||||||
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
||||||
"seed_systems": bool(seed_systems),
|
"seed_systems": bool(seed_systems),
|
||||||
}
|
}
|
||||||
|
if platforms is not None:
|
||||||
|
choices["platforms"] = list(platforms)
|
||||||
|
return choices
|
||||||
|
|
||||||
|
|
||||||
async def create_project(
|
async def create_project(
|
||||||
@@ -338,11 +356,12 @@ async def create_project(
|
|||||||
color: str = "",
|
color: str = "",
|
||||||
design_system_id: int = 0,
|
design_system_id: int = 0,
|
||||||
seed_systems: bool | None = None,
|
seed_systems: bool | None = None,
|
||||||
|
platforms: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new project in Scribe — and decide what it inherits.
|
"""Create a new project in Scribe — and decide what it inherits.
|
||||||
|
|
||||||
A project's inheritance is a decision, not a default (milestone 297):
|
A project's inheritance is a decision, not a default (milestone 297):
|
||||||
before calling, ask the operator the two inception questions and pass
|
before calling, ask the operator the three inception questions and pass
|
||||||
the answers; a project created without either is UNDECIDED and
|
the answers; a project created without either is UNDECIDED and
|
||||||
enter_project will ask until decide_project_inception records it.
|
enter_project will ask until decide_project_inception records it.
|
||||||
Defaults if nobody decides: no design system, no Systems. Rules are not
|
Defaults if nobody decides: no design system, no Systems. Rules are not
|
||||||
@@ -359,6 +378,9 @@ async def create_project(
|
|||||||
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
||||||
seed_systems: true mints the standard starter Systems (CI & Release,
|
seed_systems: true mints the standard starter Systems (CI & Release,
|
||||||
Auth & Access, …) so records can be tagged from day one.
|
Auth & Access, …) so records can be tagged from day one.
|
||||||
|
platforms: slugs of what the project is built on or ships as
|
||||||
|
(list_platforms) — they decide which family ideas reach it. The
|
||||||
|
list is the whole answer; omit it to leave the question open.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
project = await projects_svc.create_project(
|
project = await projects_svc.create_project(
|
||||||
@@ -370,7 +392,7 @@ async def create_project(
|
|||||||
color=color or None,
|
color=color or None,
|
||||||
)
|
)
|
||||||
data = project.to_dict()
|
data = project.to_dict()
|
||||||
choices = _inception_choices(design_system_id, seed_systems)
|
choices = _inception_choices(design_system_id, seed_systems, platforms)
|
||||||
if choices is not None:
|
if choices is not None:
|
||||||
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
|
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
|
||||||
data["inception"] = decided["inception"]
|
data["inception"] = decided["inception"]
|
||||||
@@ -388,6 +410,7 @@ async def decide_project_inception(
|
|||||||
project_id: int,
|
project_id: int,
|
||||||
design_system_id: int = 0,
|
design_system_id: int = 0,
|
||||||
seed_systems: bool | None = None,
|
seed_systems: bool | None = None,
|
||||||
|
platforms: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Record what a project inherits — answer enter_project's `inception` ask,
|
"""Record what a project inherits — answer enter_project's `inception` ask,
|
||||||
or re-decide later (milestone 297).
|
or re-decide later (milestone 297).
|
||||||
@@ -400,10 +423,10 @@ async def decide_project_inception(
|
|||||||
|
|
||||||
Args: as create_project's inception args. Passing nothing records a
|
Args: as create_project's inception args. Passing nothing records a
|
||||||
decision to take nothing (no design system, no seed) — a valid answer,
|
decision to take nothing (no design system, no seed) — a valid answer,
|
||||||
stated.
|
stated — and leaves the project's platforms as they are.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
choices = _inception_choices(design_system_id, seed_systems) or {}
|
choices = _inception_choices(design_system_id, seed_systems, platforms) or {}
|
||||||
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
||||||
return {"project_id": project_id, **decided}
|
return {"project_id": project_id, **decided}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Platform routes — the GLOBAL platform catalog, and which platforms a
|
||||||
|
project is (milestone 463 step 2).
|
||||||
|
|
||||||
|
Two shapes, as with the canonical areas:
|
||||||
|
|
||||||
|
- `/api/platforms` — the catalog. Readable by any signed-in user; writable
|
||||||
|
only by an admin, since a vocabulary anyone extends stops being shared.
|
||||||
|
- `/api/projects/<id>/platforms` — a project's answers, authorised by the
|
||||||
|
PROJECT (read to see them, write to change them). The service enforces
|
||||||
|
both; these are thin wrappers.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from scribe.auth import admin_required, get_current_user_id, login_required
|
||||||
|
from scribe.routes.utils import not_found
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
platforms_bp = Blueprint("platforms", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
_EDITABLE = ("name", "description", "order_index", "markers")
|
||||||
|
|
||||||
|
|
||||||
|
@platforms_bp.route("/platforms", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def list_platforms_route():
|
||||||
|
entries = await platforms_svc.list_platforms()
|
||||||
|
return jsonify({"platforms": [e.to_dict() for e in entries]})
|
||||||
|
|
||||||
|
|
||||||
|
@platforms_bp.route("/platforms", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
async def create_platform_route():
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
if not (data.get("name") or "").strip():
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
try:
|
||||||
|
entry = await platforms_svc.create_platform(
|
||||||
|
uid, data["name"], description=data.get("description"),
|
||||||
|
markers=data.get("markers"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if entry is None:
|
||||||
|
return jsonify({"error": "Permission denied"}), 403
|
||||||
|
# Duplicate-gated on the slug: the entry that already is this platform
|
||||||
|
# comes back as a 409 rather than a second spelling of it.
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
return jsonify(entry), 409
|
||||||
|
return jsonify(entry.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@platforms_bp.route("/platforms/<int:platform_id>", methods=["PATCH"])
|
||||||
|
@admin_required
|
||||||
|
async def update_platform_route(platform_id: int):
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
fields = {k: v for k, v in data.items() if k in _EDITABLE}
|
||||||
|
try:
|
||||||
|
entry = await platforms_svc.update_platform(uid, platform_id, **fields)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if entry is None:
|
||||||
|
return not_found("Platform")
|
||||||
|
return jsonify(entry.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def project_platforms_route(project_id: int):
|
||||||
|
"""Every platform the project has an answer for — rejected included, so
|
||||||
|
a screen can show "no" as well as "yes"."""
|
||||||
|
rows = await platforms_svc.project_platforms(get_current_user_id(), project_id)
|
||||||
|
if rows is None:
|
||||||
|
return not_found("Project")
|
||||||
|
return jsonify({"project_platforms": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
async def set_project_platforms_route(project_id: int):
|
||||||
|
"""Body: {"platforms": {<slug>: "declared" | "rejected" | null}}. Only
|
||||||
|
the slugs named change; null withdraws the answer. Applies whole or not
|
||||||
|
at all."""
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
try:
|
||||||
|
rows = await platforms_svc.set_project_platforms(
|
||||||
|
get_current_user_id(), project_id, data.get("platforms"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify({"project_platforms": rows})
|
||||||
@@ -103,6 +103,15 @@ async def can_admin_project(user_id: int, project_id: int) -> bool:
|
|||||||
return perm in ("admin", "owner")
|
return perm in ("admin", "owner")
|
||||||
|
|
||||||
|
|
||||||
|
async def is_instance_admin(user_id: int) -> bool:
|
||||||
|
"""Whether the user administers the INSTANCE (users.role == "admin") —
|
||||||
|
the gate on the global catalogs (canonical areas, platforms), which belong
|
||||||
|
to no user and no project, so no share can grant a write to them."""
|
||||||
|
async with async_session() as session:
|
||||||
|
role = await session.scalar(select(User.role).where(User.id == user_id))
|
||||||
|
return role == "admin"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Note / task permissions
|
# Note / task permissions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from sqlalchemy import select
|
|||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.canonical_system import CanonicalSystem
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
from scribe.models.system import System
|
from scribe.models.system import System
|
||||||
from scribe.models.user import User
|
|
||||||
from scribe.services import access
|
from scribe.services import access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -60,9 +59,7 @@ def _tokens(slug: str) -> frozenset[str]:
|
|||||||
|
|
||||||
|
|
||||||
async def _is_admin(user_id: int) -> bool:
|
async def _is_admin(user_id: int) -> bool:
|
||||||
async with async_session() as session:
|
return await access.is_instance_admin(user_id)
|
||||||
role = await session.scalar(select(User.role).where(User.id == user_id))
|
|
||||||
return role == "admin"
|
|
||||||
|
|
||||||
|
|
||||||
async def list_canonical_systems() -> list[CanonicalSystem]:
|
async def list_canonical_systems() -> list[CanonicalSystem]:
|
||||||
|
|||||||
@@ -660,6 +660,11 @@ class ArchiveScan(NamedTuple):
|
|||||||
|
|
||||||
definitions: list[ArchiveShape]
|
definitions: list[ArchiveShape]
|
||||||
references: dict[str, dict[str, int]] # path → class token → count
|
references: dict[str, dict[str, int]] # path → class token → count
|
||||||
|
# EVERY file's repo-relative path, scannable or not (milestone 463). The
|
||||||
|
# platform markers are files this scan otherwise skips — go.mod,
|
||||||
|
# AndroidManifest.xml, a Dockerfile — so detection reads the names here
|
||||||
|
# rather than re-walking the tarball.
|
||||||
|
paths: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
||||||
@@ -678,11 +683,14 @@ def scan_archive(blob: bytes) -> ArchiveScan:
|
|||||||
"""
|
"""
|
||||||
shapes: list[ArchiveShape] = []
|
shapes: list[ArchiveShape] = []
|
||||||
references: dict[str, dict[str, int]] = {}
|
references: dict[str, dict[str, int]] = {}
|
||||||
|
paths: list[str] = []
|
||||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
||||||
for member in tar:
|
for member in tar:
|
||||||
if not member.isfile() or "/" not in member.name:
|
if not member.isfile() or "/" not in member.name:
|
||||||
continue
|
continue
|
||||||
path = member.name.split("/", 1)[1]
|
path = member.name.split("/", 1)[1]
|
||||||
|
if path:
|
||||||
|
paths.append(path)
|
||||||
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
|
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
|
||||||
continue
|
continue
|
||||||
handle = tar.extractfile(member)
|
handle = tar.extractfile(member)
|
||||||
@@ -708,7 +716,7 @@ def scan_archive(blob: bytes) -> ArchiveScan:
|
|||||||
)
|
)
|
||||||
if refs:
|
if refs:
|
||||||
references[path] = refs
|
references[path] = refs
|
||||||
return ArchiveScan(shapes, references)
|
return ArchiveScan(shapes, references, tuple(paths))
|
||||||
|
|
||||||
|
|
||||||
# --- matching shapes against recorded locations ------------------------------
|
# --- matching shapes against recorded locations ------------------------------
|
||||||
@@ -806,6 +814,8 @@ async def compute_coverage(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
served: list[tuple[str, str]] = []
|
served: list[tuple[str, str]] = []
|
||||||
|
# Every file path across the project's repos, for platform detection.
|
||||||
|
tree_paths: list[str] = []
|
||||||
recorded = await _recorded_locations(user_id, project_id)
|
recorded = await _recorded_locations(user_id, project_id)
|
||||||
# The proposer's canon catalog, read once per refresh and shared across
|
# The proposer's canon catalog, read once per refresh and shared across
|
||||||
# the project's repos (#2792).
|
# the project's repos (#2792).
|
||||||
@@ -822,6 +832,7 @@ async def compute_coverage(
|
|||||||
ref = binding.ref or await forge.default_branch(api_repo)
|
ref = binding.ref or await forge.default_branch(api_repo)
|
||||||
scan = scan_archive(await forge.archive(api_repo, ref))
|
scan = scan_archive(await forge.archive(api_repo, ref))
|
||||||
definitions = scan.definitions
|
definitions = scan.definitions
|
||||||
|
tree_paths.extend(scan.paths)
|
||||||
# The head commit is provenance sugar on the ledger rows; failing to
|
# The head commit is provenance sugar on the ledger rows; failing to
|
||||||
# learn it must not fail the sync — the ref names the point well
|
# learn it must not fail the sync — the ref names the point well
|
||||||
# enough and the row timestamps carry the when.
|
# enough and the row timestamps carry the when.
|
||||||
@@ -857,6 +868,16 @@ async def compute_coverage(
|
|||||||
if not served:
|
if not served:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Platform detection (milestone 463) rides the same walk: the paths are in
|
||||||
|
# hand once. It only ever ADDS membership the project has no answer for,
|
||||||
|
# and it must not be able to fail the refresh it rides on.
|
||||||
|
try:
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
|
|
||||||
|
await platforms_svc.detect_for_project(project_id, tree_paths)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("platform detection failed for project %s", project_id, exc_info=True)
|
||||||
|
|
||||||
await shape_ledger.mark_canonicals(project_id, recorded)
|
await shape_ledger.mark_canonicals(project_id, recorded)
|
||||||
try:
|
try:
|
||||||
await shape_ledger.apply_derive_groups(project_id)
|
await shape_ledger.apply_derive_groups(project_id)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on
|
|||||||
"via": "mcp" | "ui" | "legacy",
|
"via": "mcp" | "ui" | "legacy",
|
||||||
"choices": {
|
"choices": {
|
||||||
"design_system_id": <id> | null,
|
"design_system_id": <id> | null,
|
||||||
"seed_systems": bool
|
"seed_systems": bool,
|
||||||
|
"platforms": [<slug>, ...] | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a
|
|||||||
half-applied decision is re-runnable rather than recorded as done.
|
half-applied decision is re-runnable rather than recorded as done.
|
||||||
``current_defaults`` is what the enter_project ask shows: what binds today
|
``current_defaults`` is what the enter_project ask shows: what binds today
|
||||||
if nobody decides.
|
if nobody decides.
|
||||||
|
|
||||||
|
``platforms`` (milestone 463) is which platforms the project IS — the answer
|
||||||
|
that decides which family ideas reach it. A list of catalog SLUGS, not ids:
|
||||||
|
the record is JSON, and a slug survives a backup restore onto an install whose
|
||||||
|
ids differ, where an id inside JSON would come back naming another platform.
|
||||||
|
NULL means the question was not answered here, and the project's memberships
|
||||||
|
are left exactly as they are; a list is the full answer — every platform in it
|
||||||
|
is declared, and any platform detection had added that is NOT in it is
|
||||||
|
recorded as a "no", so the next refresh cannot put it back.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -38,7 +48,7 @@ from scribe.models import async_session
|
|||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
|
|
||||||
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
||||||
CHOICE_KEYS = ("design_system_id", "seed_systems")
|
CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
|
||||||
|
|
||||||
|
|
||||||
def validate_inception(choices) -> str | None:
|
def validate_inception(choices) -> str | None:
|
||||||
@@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None:
|
|||||||
None. Pure and checked BEFORE any effect is applied: a decision either
|
None. Pure and checked BEFORE any effect is applied: a decision either
|
||||||
applies whole or errors whole (the StrictArgs lesson, #2709).
|
applies whole or errors whole (the StrictArgs lesson, #2709).
|
||||||
|
|
||||||
Accepts two keys, each optional: ``design_system_id`` an int or None,
|
Accepts three keys, each optional: ``design_system_id`` an int or None,
|
||||||
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
|
``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown
|
||||||
|
keys are an error — a typo, or a choice
|
||||||
the product no longer offers, must not become a silently ignored one."""
|
the product no longer offers, must not become a silently ignored one."""
|
||||||
if not isinstance(choices, dict):
|
if not isinstance(choices, dict):
|
||||||
return "choices must be an object"
|
return "choices must be an object"
|
||||||
@@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None:
|
|||||||
seed = choices.get("seed_systems", False)
|
seed = choices.get("seed_systems", False)
|
||||||
if not isinstance(seed, bool):
|
if not isinstance(seed, bool):
|
||||||
return "seed_systems must be true or false"
|
return "seed_systems must be true or false"
|
||||||
|
platforms = choices.get("platforms")
|
||||||
|
if platforms is not None and (
|
||||||
|
not isinstance(platforms, list)
|
||||||
|
or not all(isinstance(p, str) and p.strip() for p in platforms)
|
||||||
|
):
|
||||||
|
return "platforms must be a list of platform slugs (list_platforms), or null"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def normalize_choices(choices: dict | None) -> dict:
|
def normalize_choices(choices: dict | None) -> dict:
|
||||||
"""Both keys, always present, in canonical form — what gets stored
|
"""Every key, always present, in canonical form — what gets stored
|
||||||
and what the UI/agent reads back. Call after validate_inception."""
|
and what the UI/agent reads back. Call after validate_inception."""
|
||||||
choices = choices or {}
|
choices = choices or {}
|
||||||
|
platforms = choices.get("platforms")
|
||||||
return {
|
return {
|
||||||
"design_system_id": choices.get("design_system_id"),
|
"design_system_id": choices.get("design_system_id"),
|
||||||
"seed_systems": bool(choices.get("seed_systems", False)),
|
"seed_systems": bool(choices.get("seed_systems", False)),
|
||||||
|
"platforms": (
|
||||||
|
sorted({p.strip() for p in platforms}) if platforms is not None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -81,11 +102,15 @@ def is_decided(project) -> bool:
|
|||||||
async def current_defaults(user_id: int, project_id: int) -> dict:
|
async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||||
"""What the project inherits if nobody decides — the ask's payload.
|
"""What the project inherits if nobody decides — the ask's payload.
|
||||||
|
|
||||||
{design_system_id, design_systems: [{id,title}], systems: <count>}.
|
{design_system_id, design_systems: [{id,title}], systems: <count>,
|
||||||
|
platforms: [{slug,name}], project_platforms: [{slug,name,state}]}.
|
||||||
Instance-agnostic: an install with no design systems shows an empty list,
|
Instance-agnostic: an install with no design systems shows an empty list,
|
||||||
and the ask says so rather than inventing a default.
|
and the ask says so rather than inventing a default. `project_platforms`
|
||||||
|
is what detection has already found (and anything already answered), so
|
||||||
|
the form can start from it.
|
||||||
"""
|
"""
|
||||||
from scribe.services import design_systems as design_systems_svc
|
from scribe.services import design_systems as design_systems_svc
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
|
|
||||||
@@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
|
|||||||
raise ValueError(f"project {project_id} not found")
|
raise ValueError(f"project {project_id} not found")
|
||||||
designs = await design_systems_svc.list_design_systems(user_id)
|
designs = await design_systems_svc.list_design_systems(user_id)
|
||||||
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
||||||
|
catalog = await platforms_svc.list_platforms()
|
||||||
return {
|
return {
|
||||||
"design_system_id": project.design_system_id,
|
"design_system_id": project.design_system_id,
|
||||||
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
|
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
|
||||||
"systems": len(systems),
|
"systems": len(systems),
|
||||||
|
"platforms": [{"slug": p.slug, "name": p.name} for p in catalog],
|
||||||
|
"project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None:
|
|||||||
effect lands — a decision applies whole or errors whole."""
|
effect lands — a decision applies whole or errors whole."""
|
||||||
from scribe.services import access
|
from scribe.services import access
|
||||||
|
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
|
|
||||||
ds = choices["design_system_id"]
|
ds = choices["design_system_id"]
|
||||||
if ds is not None and not await access.can_read_design_system(user_id, ds):
|
if ds is not None and not await access.can_read_design_system(user_id, ds):
|
||||||
raise ValueError(f"design system {ds} not found (or not readable)")
|
raise ValueError(f"design system {ds} not found (or not readable)")
|
||||||
|
if choices["platforms"]:
|
||||||
|
_, unknown = await platforms_svc.resolve_slugs(choices["platforms"])
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||||
|
|
||||||
|
|
||||||
async def decide(
|
async def decide(
|
||||||
@@ -127,7 +161,8 @@ async def decide(
|
|||||||
replaces the design system and re-seeds nothing a project already has.
|
replaces the design system and re-seeds nothing a project already has.
|
||||||
|
|
||||||
Returns {"inception": <record>, "effects": {design_system_id,
|
Returns {"inception": <record>, "effects": {design_system_id,
|
||||||
systems_seeded}}.
|
systems_seeded, platforms}} — `platforms` is the project's answers after
|
||||||
|
the decision, or None when the choice was left unstated.
|
||||||
"""
|
"""
|
||||||
from scribe.services import design_systems as design_systems_svc
|
from scribe.services import design_systems as design_systems_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
@@ -152,6 +187,9 @@ async def decide(
|
|||||||
await systems_svc.seed_standard_systems(user_id, project_id)
|
await systems_svc.seed_standard_systems(user_id, project_id)
|
||||||
if choices["seed_systems"] else []
|
if choices["seed_systems"] else []
|
||||||
)
|
)
|
||||||
|
platforms = None
|
||||||
|
if choices["platforms"] is not None:
|
||||||
|
platforms = await _apply_platforms(user_id, project_id, choices["platforms"])
|
||||||
|
|
||||||
record = {
|
record = {
|
||||||
"decided_at": datetime.now(timezone.utc).isoformat(),
|
"decided_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -169,10 +207,41 @@ async def decide(
|
|||||||
"effects": {
|
"effects": {
|
||||||
"design_system_id": choices["design_system_id"],
|
"design_system_id": choices["design_system_id"],
|
||||||
"systems_seeded": [sy.name for sy in seeded],
|
"systems_seeded": [sy.name for sy in seeded],
|
||||||
|
"platforms": platforms,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]:
|
||||||
|
"""The platforms answer as membership. The list is the WHOLE answer:
|
||||||
|
every slug in it is declared, and every platform the project was a member
|
||||||
|
of (declared or detected) that is left out becomes rejected — so the next
|
||||||
|
refresh cannot detect back what the person just said the project isn't.
|
||||||
|
Platforms already rejected stay rejected; platforms never answered stay
|
||||||
|
unanswered."""
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
|
|
||||||
|
named = set(slugs)
|
||||||
|
current = await platforms_svc.project_platforms(user_id, project_id) or []
|
||||||
|
updates: dict[str, str | None] = {slug: "declared" for slug in named}
|
||||||
|
for row in current:
|
||||||
|
if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES:
|
||||||
|
updates[row["slug"]] = "rejected"
|
||||||
|
return await platforms_svc.set_project_platforms(user_id, project_id, updates)
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_line(defaults: dict) -> str:
|
||||||
|
"""What the ask says about platforms: what detection already found, and
|
||||||
|
the catalog to choose from."""
|
||||||
|
found = [
|
||||||
|
p["slug"] for p in defaults.get("project_platforms", [])
|
||||||
|
if p["state"] in ("declared", "detected")
|
||||||
|
]
|
||||||
|
catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none"
|
||||||
|
lead = f"detected so far: {', '.join(found)}; " if found else ""
|
||||||
|
return f"{lead}catalog: {catalog}"
|
||||||
|
|
||||||
|
|
||||||
async def inception_ask(user_id: int, project_id: int) -> dict:
|
async def inception_ask(user_id: int, project_id: int) -> dict:
|
||||||
"""The enter_project ask for an undecided project (milestone 297) — the
|
"""The enter_project ask for an undecided project (milestone 297) — the
|
||||||
sibling of the systems-bootstrap ask (#2683): the project's OWN current
|
sibling of the systems-bootstrap ask (#2683): the project's OWN current
|
||||||
@@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
|
|||||||
"inherits. Design system — "
|
"inherits. Design system — "
|
||||||
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
||||||
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
||||||
"once: which design system (or none), and whether to seed "
|
"once: which design system (or none), whether to seed "
|
||||||
"the standard starter Systems — then record the answers. This ask repeats on "
|
"the standard starter Systems, and which platforms the project is "
|
||||||
"every enter_project until a decision is recorded."
|
f"built on or ships as ({_platform_line(defaults)}) — then record the "
|
||||||
|
"answers. This ask repeats on every enter_project until a decision "
|
||||||
|
"is recorded."
|
||||||
),
|
),
|
||||||
"call": (
|
"call": (
|
||||||
f"decide_project_inception(project_id={project_id}, "
|
f"decide_project_inception(project_id={project_id}, "
|
||||||
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
|
"design_system_id=<id | -1 for none>, seed_systems=<true|false>, "
|
||||||
|
"platforms=[<slug>, ...])"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""Platforms — what a project is built on or ships as, and which projects are
|
||||||
|
which (milestone 463 step 2).
|
||||||
|
|
||||||
|
Membership is what makes "is this idea in family for that project?" a lookup
|
||||||
|
rather than a judgment: a family idea is for some platforms, and it reaches
|
||||||
|
every project that is a member of one of them.
|
||||||
|
|
||||||
|
Three ways a project becomes, or refuses to become, a member:
|
||||||
|
|
||||||
|
- **declared** — a person said so: at inception, or in the project's settings.
|
||||||
|
- **detected** — a marker file in a bound repo said so, found by the coverage
|
||||||
|
refresh that already walks the repo archive.
|
||||||
|
- **rejected** — a person said NO. Kept as a row, so the next refresh does not
|
||||||
|
detect it straight back.
|
||||||
|
|
||||||
|
The one invariant everything here protects: **detection only ever ADDS, and
|
||||||
|
only where nobody has answered.** It never overwrites a declared or rejected
|
||||||
|
row, and it never removes anything — not even a detected row whose marker has
|
||||||
|
since disappeared, because membership is what adoption rows hang off and a
|
||||||
|
platform flickering in and out with a repo's file tree would churn a ledger
|
||||||
|
of decisions nobody re-made.
|
||||||
|
|
||||||
|
The catalog itself is global, like the canonical area catalog, and for the
|
||||||
|
same reason writes to it are admin-only: a shared vocabulary anyone can extend
|
||||||
|
stops being shared. Reads are open to any signed-in user.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.family import Platform, ProjectPlatform
|
||||||
|
from scribe.services import access
|
||||||
|
from scribe.services.canonical_systems import canonical_slug
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The states that make a project a member. `rejected` is an answer, not
|
||||||
|
# membership.
|
||||||
|
MEMBER_STATES = ("declared", "detected")
|
||||||
|
# What a person may set from a door. `detected` is the refresh's to write.
|
||||||
|
SETTABLE_STATES = ("declared", "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
# --- the catalog -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_platforms() -> list[Platform]:
|
||||||
|
"""The whole live catalog, in display order. Global — no owner filter."""
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Platform)
|
||||||
|
.where(Platform.deleted_at.is_(None))
|
||||||
|
.order_by(Platform.order_index.asc(), Platform.name.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_markers(markers) -> list[str]:
|
||||||
|
"""Markers as stored: stripped, non-empty strings, de-duplicated in order.
|
||||||
|
Anything else is refused by the caller before it gets here."""
|
||||||
|
seen: list[str] = []
|
||||||
|
for m in markers or []:
|
||||||
|
m = str(m).strip()
|
||||||
|
if m and m not in seen:
|
||||||
|
seen.append(m)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def validate_markers(markers) -> str | None:
|
||||||
|
"""The error a markers value would earn, or None. Pure."""
|
||||||
|
if markers is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
|
||||||
|
return "markers must be a list of glob patterns"
|
||||||
|
if any(m.strip().startswith("/") for m in markers):
|
||||||
|
return "markers are repo-relative — no leading slash"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_platform(
|
||||||
|
user_id: int, name: str, *, description: str | None = None,
|
||||||
|
markers: list[str] | None = None,
|
||||||
|
) -> Platform | dict | None:
|
||||||
|
"""Add a platform to the global catalog. Admin only.
|
||||||
|
|
||||||
|
Duplicate-gated on the slug, so "Android App" cannot be added beside
|
||||||
|
"Android app": the existing entry comes back instead of a second spelling
|
||||||
|
of it. None means not permitted, or no usable name.
|
||||||
|
"""
|
||||||
|
if not await access.is_instance_admin(user_id):
|
||||||
|
return None
|
||||||
|
slug = canonical_slug(name)
|
||||||
|
if not slug:
|
||||||
|
return None
|
||||||
|
error = validate_markers(markers)
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
async with async_session() as session:
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return {
|
||||||
|
"duplicate": True,
|
||||||
|
"existing_id": existing.id,
|
||||||
|
"message": (
|
||||||
|
f"'{existing.name}' (#{existing.id}) is already this platform — "
|
||||||
|
f"both names reduce to '{slug}'."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
highest = await session.scalar(
|
||||||
|
select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
|
||||||
|
)
|
||||||
|
entry = Platform(
|
||||||
|
name=" ".join(name.split()),
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
markers=_clean_markers(markers),
|
||||||
|
order_index=(highest or 0) + 1,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
|
||||||
|
"""Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
|
||||||
|
|
||||||
|
A rename recomputes the slug — the display name and the match key must not
|
||||||
|
disagree. Changing the slug of a platform projects already belong to is
|
||||||
|
safe: membership is by id; only a backup carries the slug.
|
||||||
|
"""
|
||||||
|
if not await access.is_instance_admin(user_id):
|
||||||
|
return None
|
||||||
|
if "markers" in fields:
|
||||||
|
error = validate_markers(fields["markers"])
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
async with async_session() as session:
|
||||||
|
entry = await session.get(Platform, platform_id)
|
||||||
|
if entry is None or entry.deleted_at is not None:
|
||||||
|
return None
|
||||||
|
if fields.get("name"):
|
||||||
|
entry.name = " ".join(str(fields["name"]).split())
|
||||||
|
entry.slug = canonical_slug(entry.name)
|
||||||
|
if fields.get("description") is not None:
|
||||||
|
entry.description = fields["description"] or None
|
||||||
|
if fields.get("order_index") is not None:
|
||||||
|
entry.order_index = int(fields["order_index"])
|
||||||
|
if fields.get("markers") is not None:
|
||||||
|
entry.markers = _clean_markers(fields["markers"])
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
|
||||||
|
"""{slug: id} for the slugs the live catalog knows, and the ones it does
|
||||||
|
not. Doors take slugs — readable, stable across installs, and the form an
|
||||||
|
inception record and a backup both carry."""
|
||||||
|
wanted = [s for s in dict.fromkeys(slugs or [])]
|
||||||
|
if not wanted:
|
||||||
|
return {}, []
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Platform.slug, Platform.id).where(
|
||||||
|
Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
known = {slug: pid for slug, pid in rows}
|
||||||
|
return known, [s for s in wanted if s not in known]
|
||||||
|
|
||||||
|
|
||||||
|
# --- detection (pure) --------------------------------------------------------
|
||||||
|
|
||||||
|
def marker_matches(marker: str, path: str) -> bool:
|
||||||
|
"""Whether one marker matches one repo-relative path.
|
||||||
|
|
||||||
|
A marker with no slash matches a file's BASENAME anywhere in the tree
|
||||||
|
(`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
|
||||||
|
matches the whole repo-relative path (`.github/workflows/*`), so a
|
||||||
|
directory-shaped marker cannot be satisfied by a same-named file
|
||||||
|
somewhere else.
|
||||||
|
"""
|
||||||
|
marker = marker.strip()
|
||||||
|
if not marker:
|
||||||
|
return False
|
||||||
|
if "/" in marker:
|
||||||
|
return fnmatch.fnmatchcase(path, marker)
|
||||||
|
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
|
||||||
|
|
||||||
|
|
||||||
|
def detect(catalog: list, paths: list[str]) -> list[int]:
|
||||||
|
"""The ids of every catalog platform at least one of whose markers matches
|
||||||
|
at least one path. Pure, so it is testable against a fixture tree with no
|
||||||
|
forge and no database. A platform with no markers is never detected —
|
||||||
|
that is what declare-only means."""
|
||||||
|
hits: list[int] = []
|
||||||
|
for platform in catalog:
|
||||||
|
markers = list(getattr(platform, "markers", None) or [])
|
||||||
|
if markers and any(marker_matches(m, p) for m in markers for p in paths):
|
||||||
|
hits.append(platform.id)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
# --- membership ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
|
||||||
|
"""Every platform the project has an answer for, joined to the catalog.
|
||||||
|
None when the caller cannot read the project.
|
||||||
|
|
||||||
|
Rejected rows are included — a settings screen has to show "no" as well
|
||||||
|
as "yes", or it cannot let anyone change their mind.
|
||||||
|
"""
|
||||||
|
if not await access.can_read_project(user_id, project_id):
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(ProjectPlatform, Platform)
|
||||||
|
.join(Platform, Platform.id == ProjectPlatform.platform_id)
|
||||||
|
.where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
|
||||||
|
.order_by(Platform.order_index.asc(), Platform.name.asc())
|
||||||
|
)).all()
|
||||||
|
return [
|
||||||
|
{"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
|
||||||
|
for m, p in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def members(platforms: list[dict]) -> list[dict]:
|
||||||
|
"""The rows of `project_platforms` that are membership — the brief form
|
||||||
|
enter_project and get_project carry."""
|
||||||
|
return [
|
||||||
|
{"slug": p["slug"], "name": p["name"], "state": p["state"]}
|
||||||
|
for p in platforms if p["state"] in MEMBER_STATES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_updates(updates) -> str | None:
|
||||||
|
"""The error a set of membership answers would earn, or None. Pure.
|
||||||
|
|
||||||
|
`updates` is {slug: "declared" | "rejected" | None}. None withdraws the
|
||||||
|
answer — the row goes, and detection may add the platform again later.
|
||||||
|
`detected` is not settable: it is what the refresh writes, and a person
|
||||||
|
claiming it would erase the difference between the two."""
|
||||||
|
if not isinstance(updates, dict):
|
||||||
|
return "platforms must be an object of {slug: state}"
|
||||||
|
for slug, state in updates.items():
|
||||||
|
if not isinstance(slug, str) or not slug:
|
||||||
|
return "each platform is named by its slug"
|
||||||
|
if state is not None and state not in SETTABLE_STATES:
|
||||||
|
return (
|
||||||
|
f"'{state}' is not a state a person sets — use one of "
|
||||||
|
f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_platforms(
|
||||||
|
user_id: int, project_id: int, updates: dict[str, str | None],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Apply a person's answers about a project's platforms. Write-gated on
|
||||||
|
the project (rule 78). Platforms not named are left exactly as they are.
|
||||||
|
|
||||||
|
Raises ValueError, naming the problem, on a malformed update, an unknown
|
||||||
|
slug, or no write access — before anything is written, so the update
|
||||||
|
applies whole or not at all.
|
||||||
|
"""
|
||||||
|
error = validate_updates(updates)
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
if not await access.can_write_project(user_id, project_id):
|
||||||
|
raise ValueError(f"project {project_id} not found or no write access")
|
||||||
|
known, unknown = await resolve_slugs(list(updates))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||||
|
async with async_session() as session:
|
||||||
|
existing = {
|
||||||
|
row.platform_id: row for row in (await session.execute(
|
||||||
|
select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
for slug, state in updates.items():
|
||||||
|
pid = known[slug]
|
||||||
|
row = existing.get(pid)
|
||||||
|
if state is None:
|
||||||
|
if row is not None:
|
||||||
|
await session.delete(row)
|
||||||
|
elif row is None:
|
||||||
|
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
|
||||||
|
else:
|
||||||
|
row.state = state
|
||||||
|
await session.commit()
|
||||||
|
return await project_platforms(user_id, project_id) or []
|
||||||
|
|
||||||
|
|
||||||
|
async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
|
||||||
|
"""Add `detected` membership for each platform the project has NO answer
|
||||||
|
for. Returns the ids actually added.
|
||||||
|
|
||||||
|
The refresh's writer, so it takes no user: it runs on the owner's behalf
|
||||||
|
inside a sync the owner's keyring authorised. It never updates or deletes
|
||||||
|
a row — see the module docstring for why that is the whole contract.
|
||||||
|
"""
|
||||||
|
if not platform_ids:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
answered = set((await session.execute(
|
||||||
|
select(ProjectPlatform.platform_id).where(
|
||||||
|
ProjectPlatform.project_id == project_id,
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
|
||||||
|
for pid in added:
|
||||||
|
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
|
||||||
|
await session.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
|
||||||
|
"""Run detection over a refresh's paths and record what is new. The
|
||||||
|
coverage refresh's single call — it must not be able to fail that
|
||||||
|
refresh, so the caller wraps it."""
|
||||||
|
hits = detect(await list_platforms(), paths)
|
||||||
|
added = await record_detected(project_id, hits)
|
||||||
|
if added:
|
||||||
|
logger.info("project %s: detected platform(s) %s", project_id, added)
|
||||||
|
return added
|
||||||
|
|
||||||
@@ -512,3 +512,18 @@ def skill_text(name: str) -> str:
|
|||||||
folder = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills" / name
|
folder = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills" / name
|
||||||
refs = sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
|
refs = sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
|
||||||
return "\n\n".join(p.read_text() for p in [folder / "SKILL.md", *refs])
|
return "\n\n".join(p.read_text() for p in [folder / "SKILL.md", *refs])
|
||||||
|
|
||||||
|
|
||||||
|
def forge_tarball(files: dict[str, bytes], top: str = "widget") -> bytes:
|
||||||
|
"""A gzipped tar shaped like a forge archive: every file under one
|
||||||
|
top-level directory (repo-ref/), which the scan strips."""
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||||
|
for path, data in files.items():
|
||||||
|
info = tarfile.TarInfo(f"{top}/{path}")
|
||||||
|
info.size = len(data)
|
||||||
|
tar.addfile(info, io.BytesIO(data))
|
||||||
|
return buf.getvalue()
|
||||||
|
|||||||
+21
-3
@@ -27,7 +27,7 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
|
|||||||
|
|
||||||
|
|
||||||
def test_validate_inception_pins_the_choice_vocabulary():
|
def test_validate_inception_pins_the_choice_vocabulary():
|
||||||
assert CHOICE_KEYS == ("design_system_id", "seed_systems")
|
assert CHOICE_KEYS == ("design_system_id", "seed_systems", "platforms")
|
||||||
assert validate_inception({}) is None
|
assert validate_inception({}) is None
|
||||||
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
|
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
|
||||||
assert validate_inception({"design_system_id": None}) is None
|
assert validate_inception({"design_system_id": None}) is None
|
||||||
@@ -46,10 +46,28 @@ def test_validate_inception_pins_the_choice_vocabulary():
|
|||||||
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_inception_takes_platforms_as_slugs_or_unstated():
|
||||||
|
"""Slugs, not ids: an inception record outlives a restore, and a slug is
|
||||||
|
what both sides of a restore agree on. None is "not answered", which is
|
||||||
|
different from [] — "none of them"."""
|
||||||
|
assert validate_inception({"platforms": ["android-app", "go"]}) is None
|
||||||
|
assert validate_inception({"platforms": []}) is None
|
||||||
|
assert validate_inception({"platforms": None}) is None
|
||||||
|
assert "platform slugs" in validate_inception({"platforms": "android-app"})
|
||||||
|
assert "platform slugs" in validate_inception({"platforms": [3]})
|
||||||
|
assert "platform slugs" in validate_inception({"platforms": [" "]})
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_choices_is_canonical_and_complete():
|
def test_normalize_choices_is_canonical_and_complete():
|
||||||
out = normalize_choices({"design_system_id": 4})
|
out = normalize_choices({"design_system_id": 4})
|
||||||
assert out == {"design_system_id": 4, "seed_systems": False}
|
assert out == {"design_system_id": 4, "seed_systems": False, "platforms": None}
|
||||||
assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
|
assert normalize_choices(None) == {
|
||||||
|
"design_system_id": None, "seed_systems": False, "platforms": None,
|
||||||
|
}
|
||||||
|
# Sorted and de-duplicated, so two equal answers record identically.
|
||||||
|
assert normalize_choices({"platforms": ["go", " android-app", "go"]})["platforms"] == [
|
||||||
|
"android-app", "go",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
||||||
|
|||||||
@@ -37,14 +37,16 @@ async def seeded():
|
|||||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||||
owner, pid = seeded["owner"], seeded["pid"]
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
defaults = await inception_svc.current_defaults(owner, pid)
|
defaults = await inception_svc.current_defaults(owner, pid)
|
||||||
assert set(defaults) == {"design_system_id", "design_systems", "systems"}
|
assert set(defaults) == {
|
||||||
|
"design_system_id", "design_systems", "systems", "platforms", "project_platforms",
|
||||||
|
}
|
||||||
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
||||||
|
|
||||||
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
"design_system_id": None,
|
"design_system_id": None,
|
||||||
"seed_systems": True,
|
"seed_systems": True,
|
||||||
})
|
})
|
||||||
assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
|
assert set(out["effects"]) == {"design_system_id", "systems_seeded", "platforms"}
|
||||||
catalog = await canonical_svc.list_canonical_systems()
|
catalog = await canonical_svc.list_canonical_systems()
|
||||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||||
# Seeded Systems come out mapped, not needing a later reconciliation.
|
# Seeded Systems come out mapped, not needing a later reconciliation.
|
||||||
@@ -56,7 +58,9 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
|||||||
project = await s.get(Project, pid)
|
project = await s.get(Project, pid)
|
||||||
assert inception_svc.is_decided(project)
|
assert inception_svc.is_decided(project)
|
||||||
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||||
assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
|
assert project.inception["choices"] == {
|
||||||
|
"design_system_id": None, "seed_systems": True, "platforms": None,
|
||||||
|
}
|
||||||
# Re-deciding with seed again mints nothing twice.
|
# Re-deciding with seed again mints nothing twice.
|
||||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||||
assert again["effects"]["systems_seeded"] == []
|
assert again["effects"]["systems_seeded"] == []
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Real-Postgres checks for platform membership (milestone 463 step 2).
|
||||||
|
|
||||||
|
The one invariant: detection only ever ADDS, and only where nobody has
|
||||||
|
answered. A refresh that overwrote a "no" would put a project back into a
|
||||||
|
family it was taken out of; one that overwrote a "yes" would erase who said
|
||||||
|
it. Each is shown here against the real writer, beside the person-facing
|
||||||
|
writes it must defer to.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.family import Platform
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.services import inception as inception_svc
|
||||||
|
from scribe.services import platforms as platforms_svc
|
||||||
|
from tests.helpers import ensure_user
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||||
|
|
||||||
|
OWNER = "platforms_owner"
|
||||||
|
|
||||||
|
|
||||||
|
async def _id(slug: str) -> int:
|
||||||
|
async with async_session() as s:
|
||||||
|
return await s.scalar(
|
||||||
|
select(Platform.id).where(Platform.slug == slug, Platform.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def seeded():
|
||||||
|
"""An owner, an outsider and a fresh project. Each test gets its own
|
||||||
|
project, so answers never leak between tests through a shared row."""
|
||||||
|
async with async_session() as s:
|
||||||
|
owner = await ensure_user(s, OWNER)
|
||||||
|
outsider = await ensure_user(s, "platforms_outsider")
|
||||||
|
project = Project(user_id=owner.id, title="an app with a server")
|
||||||
|
s.add(project)
|
||||||
|
await s.flush()
|
||||||
|
ids = {"owner": owner.id, "outsider": outsider.id, "pid": project.id}
|
||||||
|
await s.commit()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _states(rows: list[dict]) -> dict[str, str]:
|
||||||
|
return {r["slug"]: r["state"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detection_adds_only_where_nobody_has_answered(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
await platforms_svc.set_project_platforms(owner, pid, {
|
||||||
|
"go": "declared", "rust": "rejected",
|
||||||
|
})
|
||||||
|
added = await platforms_svc.detect_for_project(pid, [
|
||||||
|
"server/go.mod", "core/Cargo.toml", "app/src/main/AndroidManifest.xml",
|
||||||
|
])
|
||||||
|
# Only android was unanswered; go and rust keep the person's answers.
|
||||||
|
assert added == [await _id("android-app")]
|
||||||
|
states = _states(await platforms_svc.project_platforms(owner, pid))
|
||||||
|
assert states == {"go": "declared", "rust": "rejected", "android-app": "detected"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detection_never_removes_a_platform_whose_marker_is_gone(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
await platforms_svc.detect_for_project(pid, ["server/go.mod"])
|
||||||
|
await platforms_svc.detect_for_project(pid, ["README.md"])
|
||||||
|
assert _states(await platforms_svc.project_platforms(owner, pid)) == {"go": "detected"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_withdrawn_answer_lets_detection_decide_again(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
await platforms_svc.set_project_platforms(owner, pid, {"go": "rejected"})
|
||||||
|
assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == []
|
||||||
|
await platforms_svc.set_project_platforms(owner, pid, {"go": None})
|
||||||
|
assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == [await _id("go")]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_bad_update_writes_nothing(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
with pytest.raises(ValueError, match="unknown platform"):
|
||||||
|
await platforms_svc.set_project_platforms(owner, pid, {
|
||||||
|
"go": "declared", "not-a-platform": "declared",
|
||||||
|
})
|
||||||
|
assert await platforms_svc.project_platforms(owner, pid) == []
|
||||||
|
with pytest.raises(ValueError, match="no write access"):
|
||||||
|
await platforms_svc.set_project_platforms(seeded["outsider"], pid, {"go": "declared"})
|
||||||
|
assert await platforms_svc.project_platforms(seeded["outsider"], pid) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inception_records_slugs_and_rejects_what_it_leaves_out(seeded):
|
||||||
|
"""The inception list is the WHOLE answer. Detection found Android and
|
||||||
|
Go; the person says the project is Go and Python — so Android becomes a
|
||||||
|
recorded "no", and the next refresh cannot bring it back."""
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml", "go.mod"])
|
||||||
|
defaults = await inception_svc.current_defaults(owner, pid)
|
||||||
|
assert {"android-app", "go"} <= {p["slug"] for p in defaults["project_platforms"]}
|
||||||
|
|
||||||
|
out = await inception_svc.decide(owner, pid, via="ui", choices={
|
||||||
|
"seed_systems": False, "platforms": ["python", "go"],
|
||||||
|
})
|
||||||
|
assert _states(out["effects"]["platforms"]) == {
|
||||||
|
"android-app": "rejected", "go": "declared", "python": "declared",
|
||||||
|
}
|
||||||
|
async with async_session() as s:
|
||||||
|
project = await s.get(Project, pid)
|
||||||
|
assert project.inception["choices"]["platforms"] == ["go", "python"]
|
||||||
|
assert await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_unknown_inception_platform_applies_nothing(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
with pytest.raises(ValueError, match="unknown platform"):
|
||||||
|
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
|
"seed_systems": True, "platforms": ["go", "cobol-mainframe"],
|
||||||
|
})
|
||||||
|
assert await platforms_svc.project_platforms(owner, pid) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_catalog_is_admin_written_and_duplicate_gated():
|
||||||
|
async with async_session() as s:
|
||||||
|
admin = await ensure_user(s, "platforms_admin", role="admin")
|
||||||
|
user = await ensure_user(s, OWNER)
|
||||||
|
await s.commit()
|
||||||
|
admin_id, user_id = admin.id, user.id
|
||||||
|
assert await platforms_svc.create_platform(user_id, "Kotlin Multiplatform") is None
|
||||||
|
# "Android App" reduces to the seeded android-app: the existing one comes
|
||||||
|
# back rather than a second spelling of it.
|
||||||
|
dup = await platforms_svc.create_platform(admin_id, "Android App")
|
||||||
|
assert dup["duplicate"] and dup["existing_id"] == await _id("android-app")
|
||||||
|
with pytest.raises(ValueError, match="repo-relative"):
|
||||||
|
await platforms_svc.create_platform(admin_id, "Odd", markers=["/abs"])
|
||||||
@@ -25,6 +25,18 @@ def _no_systems():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_platforms():
|
||||||
|
"""enter_project and get_project carry the project's platforms (milestone
|
||||||
|
463). No database in this lane, so the lookup is stubbed to the common
|
||||||
|
case — a project with none yet. The populated shape is asserted in its
|
||||||
|
own test.
|
||||||
|
"""
|
||||||
|
with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
|
||||||
|
AsyncMock(return_value=[])):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_coverage():
|
def _no_coverage():
|
||||||
"""enter_project also reads the pattern-coverage cache (#2692) — same
|
"""enter_project also reads the pattern-coverage cache (#2692) — same
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ PLAN = "A plan paragraph long enough to matter. " * 125 # ~5k chars
|
|||||||
GOAL = "What the project is for. " * 50 # ~1.2k chars
|
GOAL = "What the project is for. " * 50 # ~1.2k chars
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_platforms():
|
||||||
|
"""enter_project and get_project carry the project's platforms (milestone
|
||||||
|
463). No database in this lane, so the lookup is stubbed to the common
|
||||||
|
case — a project with none yet. The populated shape is asserted in its
|
||||||
|
own test.
|
||||||
|
"""
|
||||||
|
with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
|
||||||
|
AsyncMock(return_value=[])):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _milestone(mid: int, status: str, touched_day: int) -> dict:
|
def _milestone(mid: int, status: str, touched_day: int) -> dict:
|
||||||
"""A summary row as get_project_milestone_summary returns it."""
|
"""A summary row as get_project_milestone_summary returns it."""
|
||||||
touched = f"2026-08-{touched_day:02d}T00:00:00+00:00"
|
touched = f"2026-08-{touched_day:02d}T00:00:00+00:00"
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ deliberately reuse the definitions test_write_path_trigger stages for the
|
|||||||
hook; extending one detector means extending both, and this comment is the
|
hook; extending one detector means extending both, and this comment is the
|
||||||
tripwire.
|
tripwire.
|
||||||
"""
|
"""
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -29,7 +27,7 @@ from scribe.services.coverage import (
|
|||||||
shapes_from_archive,
|
shapes_from_archive,
|
||||||
)
|
)
|
||||||
from scribe.services.shape_ledger import location_covers
|
from scribe.services.shape_ledger import location_covers
|
||||||
from tests.helpers import ensure_user
|
from tests.helpers import ensure_user, forge_tarball
|
||||||
|
|
||||||
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
||||||
|
|
||||||
@@ -173,14 +171,7 @@ def test_scannable_gates_prose_vendored_and_sourcemaps():
|
|||||||
# --- unit: reading shapes out of a forge tarball -----------------------------
|
# --- unit: reading shapes out of a forge tarball -----------------------------
|
||||||
|
|
||||||
|
|
||||||
def _tarball(files: dict[str, bytes], top: str = "widget") -> bytes:
|
_tarball = forge_tarball # shared with tests/test_platforms.py
|
||||||
buf = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
||||||
for path, data in files.items():
|
|
||||||
info = tarfile.TarInfo(f"{top}/{path}")
|
|
||||||
info.size = len(data)
|
|
||||||
tar.addfile(info, io.BytesIO(data))
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
TREE = {
|
TREE = {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Platforms without a database (milestone 463 step 2).
|
||||||
|
|
||||||
|
Detection is pure — a catalog and a list of repo paths in, platform ids out —
|
||||||
|
so it is pinned here against fixture trees. What it writes, and what it must
|
||||||
|
never overwrite, is in tests/test_integration_platforms.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
|
||||||
|
from scribe.mcp.tools.projects import _inception_choices
|
||||||
|
from scribe.services.coverage import scan_archive
|
||||||
|
from tests.helpers import forge_tarball
|
||||||
|
from scribe.services.platforms import (
|
||||||
|
MEMBER_STATES, SETTABLE_STATES, detect, marker_matches, members,
|
||||||
|
validate_markers, validate_updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _platform(pid: int, *markers: str) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(id=pid, markers=list(markers))
|
||||||
|
|
||||||
|
|
||||||
|
# --- a single marker ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_bare_marker_matches_the_basename_anywhere_in_the_tree():
|
||||||
|
assert marker_matches("go.mod", "go.mod")
|
||||||
|
assert marker_matches("go.mod", "services/api/go.mod")
|
||||||
|
assert marker_matches("AndroidManifest.xml", "app/src/main/AndroidManifest.xml")
|
||||||
|
assert marker_matches("vite.config.*", "frontend/vite.config.ts")
|
||||||
|
assert not marker_matches("go.mod", "go.mod.bak")
|
||||||
|
assert not marker_matches("go.mod", "docs/go.mod.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_marker_with_a_slash_matches_the_whole_path_only():
|
||||||
|
"""A directory-shaped marker cannot be satisfied by a same-named file
|
||||||
|
somewhere else — `.github/workflows/*` is about the repo root's CI, not
|
||||||
|
a vendored copy of some other project's."""
|
||||||
|
assert marker_matches(".github/workflows/*", ".github/workflows/ci.yml")
|
||||||
|
assert not marker_matches(".github/workflows/*", "vendor/x/.github/workflows/ci.yml")
|
||||||
|
assert not marker_matches(".github/workflows/*", "ci.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blank_marker_matches_nothing():
|
||||||
|
assert not marker_matches("", "anything")
|
||||||
|
assert not marker_matches(" ", "anything")
|
||||||
|
|
||||||
|
|
||||||
|
# --- detection over a tree -------------------------------------------------------
|
||||||
|
|
||||||
|
ANDROID_AND_GO = [
|
||||||
|
"app/build.gradle.kts",
|
||||||
|
"app/src/main/AndroidManifest.xml",
|
||||||
|
"server/go.mod",
|
||||||
|
"server/main.go",
|
||||||
|
"README.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_finds_every_platform_a_tree_carries_and_nothing_else():
|
||||||
|
catalog = [
|
||||||
|
_platform(1, "AndroidManifest.xml"),
|
||||||
|
_platform(2, "go.mod"),
|
||||||
|
_platform(3, "Cargo.toml"),
|
||||||
|
_platform(4, ".github/workflows/*"),
|
||||||
|
]
|
||||||
|
assert detect(catalog, ANDROID_AND_GO) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_platform_with_no_markers_is_declare_only():
|
||||||
|
"""An empty marker list is how a platform says "a person must tell you" —
|
||||||
|
it must not match everything, and it must not match nothing by error."""
|
||||||
|
catalog = [_platform(1), _platform(2, "go.mod")]
|
||||||
|
assert detect(catalog, ANDROID_AND_GO) == [2]
|
||||||
|
assert detect([SimpleNamespace(id=9, markers=None)], ANDROID_AND_GO) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_on_an_empty_tree_finds_nothing():
|
||||||
|
assert detect([_platform(1, "go.mod")], []) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_archive_scan_carries_every_path_not_only_the_scannable_ones():
|
||||||
|
"""The markers that say what a project IS are mostly files the shape scan
|
||||||
|
skips — a manifest, a go.mod, a gradle script. Detection reads the scan's
|
||||||
|
paths, so a scan that listed only code files would detect nothing."""
|
||||||
|
scan = scan_archive(forge_tarball({
|
||||||
|
"app/src/main/AndroidManifest.xml": b"<manifest/>",
|
||||||
|
"server/go.mod": b"module x\n",
|
||||||
|
"server/main.py": b"def main():\n pass\n",
|
||||||
|
}))
|
||||||
|
assert set(scan.paths) == {
|
||||||
|
"app/src/main/AndroidManifest.xml", "server/go.mod", "server/main.py",
|
||||||
|
}
|
||||||
|
# The forge's wrapping directory is stripped, as it is for shapes.
|
||||||
|
assert not any(p.startswith("widget/") for p in scan.paths)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the answers a person may give -------------------------------------------------
|
||||||
|
|
||||||
|
def test_detected_is_the_refreshs_to_write_never_a_persons():
|
||||||
|
assert "detected" in MEMBER_STATES
|
||||||
|
assert "detected" not in SETTABLE_STATES
|
||||||
|
assert "rejected" not in MEMBER_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_updates():
|
||||||
|
assert validate_updates({"go": "declared", "rust": "rejected", "python": None}) is None
|
||||||
|
assert validate_updates({}) is None
|
||||||
|
assert "slug: state" in validate_updates(["go"])
|
||||||
|
assert "not a state a person sets" in validate_updates({"go": "detected"})
|
||||||
|
assert "not a state a person sets" in validate_updates({"go": "maybe"})
|
||||||
|
assert "named by its slug" in validate_updates({"": "declared"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_markers():
|
||||||
|
assert validate_markers(None) is None
|
||||||
|
assert validate_markers(["go.mod", ".github/workflows/*"]) is None
|
||||||
|
assert "list of glob patterns" in validate_markers("go.mod")
|
||||||
|
assert "list of glob patterns" in validate_markers([1])
|
||||||
|
assert "repo-relative" in validate_markers(["/etc/passwd"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_members_drops_the_rejected_answers():
|
||||||
|
rows = [
|
||||||
|
{"id": 1, "slug": "go", "name": "Go", "state": "declared"},
|
||||||
|
{"id": 2, "slug": "rust", "name": "Rust", "state": "rejected"},
|
||||||
|
{"id": 3, "slug": "python", "name": "Python", "state": "detected"},
|
||||||
|
]
|
||||||
|
assert [m["slug"] for m in members(rows)] == ["go", "python"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the doors ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_the_platform_tools_are_classified():
|
||||||
|
assert "list_platforms" in _READ_ONLY_TOOLS
|
||||||
|
assert "set_project_platforms" in _WRITE_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def test_inception_choices_carry_platforms_only_when_given():
|
||||||
|
"""Left out, platforms stays UNSTATED (None after normalising) — which is
|
||||||
|
not the same answer as an empty list, "none of them"."""
|
||||||
|
assert "platforms" not in _inception_choices(0, True)
|
||||||
|
assert _inception_choices(0, True, ["go"])["platforms"] == ["go"]
|
||||||
|
assert _inception_choices(0, True, [])["platforms"] == []
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Structural tests for the platforms blueprint (milestone 463 step 2) —
|
||||||
|
registration and the route-to-service contract. What the writes do is in
|
||||||
|
tests/test_integration_platforms.py."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
|
def test_platforms_blueprint_registered_in_app():
|
||||||
|
from scribe.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
assert "platforms" in app.blueprints
|
||||||
|
rules = {(r.rule, m) for r in app.url_map.iter_rules() for m in r.methods}
|
||||||
|
for rule, method in (
|
||||||
|
("/api/platforms", "GET"),
|
||||||
|
("/api/platforms", "POST"),
|
||||||
|
("/api/platforms/<int:platform_id>", "PATCH"),
|
||||||
|
("/api/projects/<int:project_id>/platforms", "GET"),
|
||||||
|
("/api/projects/<int:project_id>/platforms", "PUT"),
|
||||||
|
):
|
||||||
|
assert (rule, method) in rules, f"{method} {rule} is not routed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_writes_to_the_catalog_and_to_a_project_take_the_caller():
|
||||||
|
"""The catalog's admin gate and the project's write gate both live in the
|
||||||
|
service, so every write must be handed the caller to check."""
|
||||||
|
from scribe.services import platforms as svc
|
||||||
|
for name in ("create_platform", "update_platform", "set_project_platforms",
|
||||||
|
"project_platforms"):
|
||||||
|
assert "user_id" in inspect.signature(getattr(svc, name)).parameters
|
||||||
Reference in New Issue
Block a user