@things-factory/figure-service 10.1.27 → 10.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.27",
3
+ "version": "10.1.28",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,13 +25,13 @@
25
25
  "dependencies": {
26
26
  "@hatiolab/figure-model": "^0.1.21",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
- "@things-factory/ai-client-base": "^10.1.26",
29
- "@things-factory/auth-base": "^10.1.27",
28
+ "@things-factory/ai-client-base": "^10.1.28",
29
+ "@things-factory/auth-base": "^10.1.28",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.27",
31
+ "@things-factory/shell": "^10.1.28",
32
32
  "graphql-scalars": "^1.22.4",
33
33
  "graphql-upload": "^15.0.2",
34
34
  "koa-router": "^7.4.0"
35
35
  },
36
- "gitHead": "d04dc762cacf2aac8ba0b0f5d768eca365fc09da"
36
+ "gitHead": "3a260a0f79bc6b41a692a103955bfcc541aff60e"
37
37
  }
@@ -1,4 +1,4 @@
1
- import { blocksRelease, compile, sizingReport, validate } from '@hatiolab/figure-model'
1
+ import { blocksRelease, compile, contractChanges, sizingReport, validate } from '@hatiolab/figure-model'
2
2
  import type { Axis, FigureSource } from '@hatiolab/figure-model'
3
3
 
4
4
  /** Service transport shape. It deliberately contains only an explicit, reversible FigureSource edit. */
@@ -127,8 +127,11 @@ function measuredFixes(source: FigureSource, factor: number): GateFix[] {
127
127
  *
128
128
  * 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를
129
129
  * 조용히 통과시키면 빈 것이 발행된다.
130
+ *
131
+ * `releasedJson` 은 **직전 발행판의 정본**이다. 주면 그것과 견주어 「이미 놓인 인스턴스를 깨는
132
+ * 발행인가」를 함께 낸다(ADR-0063 결정 3). 한 번도 발행된 적 없으면 견줄 것이 없으므로 안 준다.
130
133
  */
131
- export function inspectSource(sourceJson?: string | null): Inspection {
134
+ export function inspectSource(sourceJson?: string | null, releasedJson?: string | null): Inspection {
132
135
  if (!sourceJson) {
133
136
  return {
134
137
  blocked: true,
@@ -187,11 +190,48 @@ export function inspectSource(sourceJson?: string | null): Inspection {
187
190
  how: finding.how,
188
191
  /* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
189
192
  at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
190
- fixes: finding.code === 'sizing-splits' ? fixes.filter(fix => fix.findingWay === finding.way) : undefined,
193
+ fixes: finding.code === 'sizing-splits' ? fixes.filter(fix => fix.findingWay === finding.way) : undefined,
191
194
  blocking: finding.code === 'sizing-splits'
192
195
  })
193
196
  }
197
+
198
+ findings.push(...contractFindings(source, releasedJson))
194
199
  }
195
200
 
196
201
  return { findings, blocked: findings.some(finding => finding.blocking) }
197
202
  }
203
+
204
+ /**
205
+ * 직전 발행판과 견준 것 — **약속을 깨는 발행인가**(ADR-0063 결정 3).
206
+ *
207
+ * 무엇이 달라졌고 그것이 막는 것인지는 형식이 정한다(`contractChanges`). 여기서는 화면과 발행 문이
208
+ * 읽는 모양으로 옮기기만 한다.
209
+ *
210
+ * **앞 판을 오늘 형식으로 못 읽으면 조용히 넘어가지 않는다.** 판 1 은 오늘 형식이 거절하는 판으로
211
+ * 적혀 있을 수 있고, 그때 견주기를 건너뛰면 아무 말 없이 관문이 없는 것이 된다. 막지는 않는다 —
212
+ * 그러면 그 도형은 영영 다시 발행할 수 없다.
213
+ */
214
+ function contractFindings(source: FigureSource, releasedJson?: string | null): GateFinding[] {
215
+ if (!releasedJson) return []
216
+
217
+ let released: FigureSource
218
+ try {
219
+ released = JSON.parse(releasedJson) as FigureSource
220
+ return contractChanges(released, source).map(change => ({
221
+ code: change.code,
222
+ message: change.message,
223
+ why: change.why,
224
+ at: change.subject,
225
+ blocking: change.blocking
226
+ }))
227
+ } catch (e) {
228
+ return [
229
+ {
230
+ code: 'released-unreadable',
231
+ message: `직전 발행판을 오늘 형식으로 읽을 수 없어 견주지 못했다: ${(e as Error).message}`,
232
+ why: '이 발행이 이미 놓인 인스턴스를 깨는지는 확인되지 않았다.',
233
+ blocking: false
234
+ }
235
+ ]
236
+ }
237
+ }
@@ -168,7 +168,19 @@ export class FigureMutation {
168
168
 
169
169
  막는 이유는 사유와 함께 낸다. 「발행할 수 없습니다」로 뭉치면 무엇을 고쳐야 하는지 모른다.
170
170
  */
171
- const inspection = inspectSource(figure.source)
171
+ /*
172
+ 직전 발행판을 먼저 꺼낸다. 판 번호를 세는 데도 쓰고, **약속을 깨는 발행인가**를 견주는 데도
173
+ 쓴다(ADR-0063 결정 3) — 도면은 type 만 적고 늘 최신 발행판을 그리므로, 다시 발행하는 것은
174
+ 그 type 을 놓아 둔 모든 도면에게 같은 약속을 다시 하는 일이다.
175
+ */
176
+ const released = await historyRepository
177
+ .createQueryBuilder('history')
178
+ .where('history.originalId = :id', { id: figure.id })
179
+ .orderBy('history.version', 'DESC')
180
+ .limit(1)
181
+ .getOne()
182
+
183
+ const inspection = inspectSource(figure.source, released?.source)
172
184
  if (inspection.blocked) {
173
185
  const reasons = inspection.findings
174
186
  .filter(finding => finding.blocking)
@@ -177,13 +189,7 @@ export class FigureMutation {
177
189
  throw new Error(`Figure '${figure.type}' 은 아직 발행할 수 없다 — ${reasons.join(' · ')}`)
178
190
  }
179
191
 
180
- const highest = await historyRepository
181
- .createQueryBuilder('history')
182
- .select('MAX(history.version)', 'max')
183
- .where('history.originalId = :id', { id: figure.id })
184
- .getRawOne()
185
-
186
- const nextVersion = highest?.max ? Number(highest.max) + 1 : 1
192
+ const nextVersion = released?.version ? Number(released.version) + 1 : 1
187
193
 
188
194
  return await repository.save({
189
195
  /*
@@ -113,7 +113,18 @@ export class FigureQuery {
113
113
  throw new Error(`Figure ${id} 를 찾을 수 없다`)
114
114
  }
115
115
 
116
- return inspectSource(figure.source)
116
+ /*
117
+ **화면이 발행 문과 같은 것을 본다.** 직전 발행판을 여기서도 함께 넘기지 않으면, 저작 화면은
118
+ 조용한데 발행을 누른 순간 계약 위반으로 막힌다 — 그때는 이미 열 군데를 고친 뒤다.
119
+ */
120
+ const released = await getRepository(FigureHistory)
121
+ .createQueryBuilder('history')
122
+ .where('history.originalId = :id', { id })
123
+ .orderBy('history.version', 'DESC')
124
+ .limit(1)
125
+ .getOne()
126
+
127
+ return inspectSource(figure.source, released?.source)
117
128
  }
118
129
 
119
130
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
@@ -0,0 +1,38 @@
1
+ /*
2
+ * figure 도구가 지나는 문 — ADR-0072 결정 2.
3
+ *
4
+ * 선언만 해 두고 아무도 읽지 않으면 그 선언은 다음 사람이 지우거나 바꿔도 아무 일이 없다. 여기서는
5
+ * 실제로 등록된 명세를 등록기에 넣고, 등록기가 무엇도 문제 삼지 않는지 묻는다. 도구 목록이 늘면
6
+ * 이 시험이 먼저 문다.
7
+ */
8
+ import { clearToolRegistry, getAllToolSpecs, getToolRegistryWarnings } from '@things-factory/ai-client-base'
9
+
10
+ import { registerFigureToolCategories } from './figure-tools'
11
+
12
+ describe('figure tools — declared doors', () => {
13
+ beforeEach(() => {
14
+ clearToolRegistry()
15
+ registerFigureToolCategories()
16
+ })
17
+ afterAll(() => clearToolRegistry())
18
+
19
+ it('★ every figure tool says which doors it reaches, as a list', () => {
20
+ const undeclared = getAllToolSpecs()
21
+ .filter(spec => !Array.isArray((spec as any).doors))
22
+ .map(spec => spec.name)
23
+
24
+ expect(undeclared).toEqual([])
25
+ })
26
+
27
+ it('★ a door a tool reaches comes with the privilege that guards it', () => {
28
+ const unguarded = getAllToolSpecs()
29
+ .filter(spec => ((spec as any).doors ?? []).length > 0 && !(spec as any).privilege)
30
+ .map(spec => spec.name)
31
+
32
+ expect(unguarded).toEqual([])
33
+ })
34
+
35
+ it('the registry itself finds nothing to report about these specs', () => {
36
+ expect(getToolRegistryWarnings().map(w => `${w.code}:${w.params.tool ?? ''}`)).toEqual([])
37
+ })
38
+ })
@@ -117,7 +117,12 @@ test('실제 registryAssistantChat 경계가 열린 모델 문맥을 Figure 도
117
117
  } } as any,
118
118
  messages: [{ role: 'user', content: '현재 모델을 조회해줘' }],
119
119
  systemPrompt: 'Read the current figure.', toolCategories: ['figure-authoring'],
120
- state: { domain: MINE }, hostContext: { figureId: 'fig-1', draftSource: SOURCE }
120
+ state: { domain: MINE }, hostContext: { figureId: 'fig-1', draftSource: SOURCE },
121
+ /*
122
+ * 도구가 닿는 문의 권한을 선언한 뒤로(ADR-0071 결정 2), 권한을 물어볼 길을 주지 않으면 그 도구는
123
+ * 닫힌다 — 그것이 맞는 기본값이다. 여기서 보는 것은 권한이 아니라 배선이므로 「가진 사람」으로 둔다.
124
+ */
125
+ toolAccess: async () => true
121
126
  })
122
127
  expect(seen).toHaveLength(2)
123
128
  expect(JSON.stringify(seen[1])).toContain('MIXER')
@@ -232,7 +232,7 @@ function unsavedFigure(host: any): any | undefined {
232
232
  const FIGURE_ID_ARG = {
233
233
  figureId: {
234
234
  type: 'string',
235
- description: 'The figure to read. Omit when host context has an open figureId or an unsaved new editor draft. On the catalog, first resolve the user’s requested figure with listFigures.'
235
+ description: 'The figure to read. Omit to read the one the person has open, saved or not — the server fills it in, and you cannot see which it is. On the catalog nothing is open, so resolve the figure the person asked for with listFigures first.'
236
236
  }
237
237
  } as const
238
238
 
@@ -247,6 +247,9 @@ function figureToolSpecs(): ToolSpec[] {
247
247
  {
248
248
  kind: 'read',
249
249
  name: 'listFigures',
250
+ /* 이 도메인의 도형 목록 — 저장소를 직접 읽지만 같은 일을 하는 문은 이것이다. */
251
+ doors: ['Query.figures'],
252
+ privilege: { category: 'figure', privilege: 'query', owner: true },
250
253
  description:
251
254
  'Lists the figures in this tenant with their type names, states and part counts. ' +
252
255
  'Call it before naming a type — type names are stored identifiers and must not be invented.',
@@ -274,9 +277,13 @@ function figureToolSpecs(): ToolSpec[] {
274
277
  {
275
278
  kind: 'read',
276
279
  name: 'getFigureSource',
280
+ /* 저장된 도형의 원본. **저장 전 초안**을 물으면 저장소에 갈 것이 없어 화면이 보낸 것을 그대로 돌려준다 —
281
+ * 그 갈래에는 닿는 문이 없지만 `doors: []` 로 적지 않는다. 그러면 저장된 도형을 읽는 권한 검사가 선언에서 사라진다. */
282
+ doors: ['Query.figure'],
283
+ privilege: { category: 'figure', privilege: 'query', owner: true },
277
284
  description:
278
285
  'Returns the canonical FigureSource of one figure, with its measured cost ' +
279
- '(triangles, draw calls, how many fit on one board). Call it before proposing a revision — ' +
286
+ '(triangles, part groups, and how many of it hold 60fps on one screen). Call it before proposing a revision — ' +
280
287
  'revising without reading it means proposing against a guess.',
281
288
  schema: { type: 'object', properties: { ...FIGURE_ID_ARG } },
282
289
  builder: async (args: any, ctx: any) => {
@@ -309,6 +316,9 @@ function figureToolSpecs(): ToolSpec[] {
309
316
  {
310
317
  kind: 'read',
311
318
  name: 'inspectFigure',
319
+ /* 검사 결과 — 같은 일을 하는 문. */
320
+ doors: ['Query.inspectFigure'],
321
+ privilege: { category: 'figure', privilege: 'query', owner: true },
312
322
  description:
313
323
  'Says whether a figure may be released and, when it may not, what is wrong — each finding ' +
314
324
  'carries why it is a problem and what to change. Call it when asked about releasing, or ' +
@@ -328,6 +338,9 @@ function figureToolSpecs(): ToolSpec[] {
328
338
  {
329
339
  kind: 'read',
330
340
  name: 'proposeFigureCandidate',
341
+ /* 후보 생성 — 저장은 하지 않지만 같은 문의 권한을 쓴다. */
342
+ doors: ['Mutation.proposeFigure'],
343
+ privilege: { category: 'figure', privilege: 'mutation', owner: true },
331
344
  description:
332
345
  'Asks for a figure candidate from a description, optionally revising an existing one. ' +
333
346
  'Returns the candidate with its score and cost. NOTHING IS SAVED — the author accepts or ' +