@things-factory/figure-service 10.1.29 → 10.1.31
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/dist-server/service/figure/figure-query.js +19 -5
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +6 -4
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/figure/figure-blueprint-query.test.ts +269 -1
- package/server/service/figure/figure-e2e-smoke.test.ts +1 -1
- package/server/service/figure/figure-query.ts +36 -7
- package/server/service/figure/figure-tool-doors.test.ts +21 -37
- package/server/service/figure/figure-tools.test.ts +66 -0
- package/server/service/figure/figure-tools.ts +6 -4
- package/translations/en.json +3 -0
- package/translations/ko.json +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.31",
|
|
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.27",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.31",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.31",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.31",
|
|
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": "
|
|
36
|
+
"gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
|
|
37
37
|
}
|
|
@@ -40,6 +40,12 @@ const sampleSource = JSON.stringify({
|
|
|
40
40
|
]
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
/** The same box under a different type and part name, so a test can tell the sources apart. */
|
|
44
|
+
const boxNamed = (type: string, part: string) => {
|
|
45
|
+
const parsed = JSON.parse(sampleSource)
|
|
46
|
+
return JSON.stringify({ ...parsed, type, parts: [{ ...parsed.parts[0], name: part }] })
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
beforeAll(async () => {
|
|
44
50
|
Figure = loadCompiled('service/figure/figure').Figure
|
|
45
51
|
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
@@ -176,13 +182,227 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
176
182
|
expect(result.groups[0].members[0].transform.size.x).toBe(100)
|
|
177
183
|
})
|
|
178
184
|
|
|
185
|
+
/*
|
|
186
|
+
The test above proves a release is reached through history, with one history row. It cannot tell
|
|
187
|
+
`version: 'DESC'` from `'ASC'`, nor from no ordering at all — with a single row every one of them
|
|
188
|
+
answers the same. Consumers are promised the LATEST release, so the ordering is the claim, and it
|
|
189
|
+
needs more than one row to be a claim at all. The rows go in out of order because an ordering bug
|
|
190
|
+
that reads insertion order would otherwise pass.
|
|
191
|
+
*/
|
|
192
|
+
it('answers the highest released version when a type has several', async () => {
|
|
193
|
+
const repo = dataSource!.getRepository(Figure)
|
|
194
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
195
|
+
|
|
196
|
+
const figure = await repo.save({
|
|
197
|
+
domain: testDomain,
|
|
198
|
+
type: 'MULTI',
|
|
199
|
+
name: 'in authoring',
|
|
200
|
+
state: 'draft',
|
|
201
|
+
source: boxNamed('MULTI', 'unreleased-draft'),
|
|
202
|
+
version: 3
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
for (const version of [1, 3, 2]) {
|
|
206
|
+
await historyRepo.save({
|
|
207
|
+
domain: testDomain,
|
|
208
|
+
originalId: figure.id,
|
|
209
|
+
type: 'MULTI',
|
|
210
|
+
name: `v${version}`,
|
|
211
|
+
state: 'released',
|
|
212
|
+
version,
|
|
213
|
+
source: boxNamed('MULTI', `v${version}`)
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = await new FigureQuery().figureBlueprint('MULTI', makeContext())
|
|
218
|
+
|
|
219
|
+
expect(result).not.toBeNull()
|
|
220
|
+
expect(result.groups[0].members[0].name).toBe('v3')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/*
|
|
224
|
+
Every edit to a released figure writes a history row, and those rows are not all releases. A
|
|
225
|
+
draft row sitting at a higher version than the last release is the ordinary state of a figure
|
|
226
|
+
being worked on, so reading the newest row rather than the newest RELEASED row would hand
|
|
227
|
+
consumers unreleased work — the leak the whole door exists to stop.
|
|
228
|
+
*/
|
|
229
|
+
it('skips a draft history that sits above the last release', async () => {
|
|
230
|
+
const repo = dataSource!.getRepository(Figure)
|
|
231
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
232
|
+
|
|
233
|
+
const figure = await repo.save({
|
|
234
|
+
domain: testDomain,
|
|
235
|
+
type: 'WIP',
|
|
236
|
+
name: 'in authoring',
|
|
237
|
+
state: 'draft',
|
|
238
|
+
source: boxNamed('WIP', 'unreleased-draft'),
|
|
239
|
+
version: 5
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
await historyRepo.save({
|
|
243
|
+
domain: testDomain,
|
|
244
|
+
originalId: figure.id,
|
|
245
|
+
type: 'WIP',
|
|
246
|
+
name: 'v2',
|
|
247
|
+
state: 'released',
|
|
248
|
+
version: 2,
|
|
249
|
+
source: boxNamed('WIP', 'released-v2')
|
|
250
|
+
})
|
|
251
|
+
await historyRepo.save({
|
|
252
|
+
domain: testDomain,
|
|
253
|
+
originalId: figure.id,
|
|
254
|
+
type: 'WIP',
|
|
255
|
+
name: 'v5',
|
|
256
|
+
state: 'draft',
|
|
257
|
+
version: 5,
|
|
258
|
+
source: boxNamed('WIP', 'draft-v5')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const result = await new FigureQuery().figureBlueprint('WIP', makeContext())
|
|
262
|
+
|
|
263
|
+
expect(result).not.toBeNull()
|
|
264
|
+
expect(result.groups[0].members[0].name).toBe('released-v2')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
History rows are looked up by originalId, which is a figure id and so already unique. That makes
|
|
269
|
+
the domain in the same where-clause look redundant, and redundant-looking filters get deleted.
|
|
270
|
+
It is not redundant: it is the tenant boundary on a door that consumers call, so a test holds it.
|
|
271
|
+
*/
|
|
272
|
+
it('does not reach a history row that belongs to another domain', async () => {
|
|
273
|
+
const domainRepo = dataSource!.getRepository(shell.Domain)
|
|
274
|
+
const other = await domainRepo.save({ name: 'other-domain', subdomain: 'other-domain' })
|
|
275
|
+
|
|
276
|
+
const repo = dataSource!.getRepository(Figure)
|
|
277
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
278
|
+
|
|
279
|
+
const figure = await repo.save({
|
|
280
|
+
domain: testDomain,
|
|
281
|
+
type: 'LEAKY',
|
|
282
|
+
name: 'never released here',
|
|
283
|
+
state: 'draft',
|
|
284
|
+
source: boxNamed('LEAKY', 'unreleased-draft'),
|
|
285
|
+
version: 0
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await historyRepo.save({
|
|
289
|
+
domain: other,
|
|
290
|
+
originalId: figure.id,
|
|
291
|
+
type: 'LEAKY',
|
|
292
|
+
name: 'released elsewhere',
|
|
293
|
+
state: 'released',
|
|
294
|
+
version: 1,
|
|
295
|
+
source: boxNamed('LEAKY', 'other-domain-release')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const result = await new FigureQuery().figureBlueprint('LEAKY', makeContext())
|
|
299
|
+
|
|
300
|
+
expect(result).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
/*
|
|
304
|
+
This used to answer null, and that was the defect. Null says one thing on this door — nobody
|
|
305
|
+
published this type — and the board reads it that way: it draws the stand-in of ADR-0063 결정 1 and
|
|
306
|
+
the scene opens. A released figure whose stored source no longer compiles is a different fact. The
|
|
307
|
+
author published it, the catalogue says released, and with null the only trace was a console line
|
|
308
|
+
on the server, so the author saw "my figure does not show on the board" and nothing more. That is
|
|
309
|
+
the silence ADR-0053 refuses (chief architect, ADR-0063 결정 2 보탬).
|
|
310
|
+
|
|
311
|
+
So it refuses with a name, and the name carries what someone needs to find the figure: the type,
|
|
312
|
+
its id, and the edition that does not compile. The sentence is built where the request's language
|
|
313
|
+
is known, which is why the gate throws no sentence of its own (ADR-0054).
|
|
314
|
+
*/
|
|
315
|
+
it('refuses with a named refusal when the stored source cannot be compiled', async () => {
|
|
316
|
+
const repo = dataSource!.getRepository(Figure)
|
|
317
|
+
const saved = await repo.save({
|
|
318
|
+
domain: testDomain,
|
|
319
|
+
type: 'BROKEN',
|
|
320
|
+
name: 'corrupted',
|
|
321
|
+
state: 'released',
|
|
322
|
+
source: '{ this is not json',
|
|
323
|
+
version: 4
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const query = new FigureQuery()
|
|
327
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
328
|
+
try {
|
|
329
|
+
await expect(query.figureBlueprint('BROKEN', makeContext())).rejects.toMatchObject({
|
|
330
|
+
name: 'Refusal',
|
|
331
|
+
code: 'figure-release-does-not-compile',
|
|
332
|
+
params: { type: 'BROKEN', figureId: saved.id, version: 4 }
|
|
333
|
+
})
|
|
334
|
+
} finally {
|
|
335
|
+
spy.mockRestore()
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
The edition named in that refusal is the one the board was handed, which is not the row's own
|
|
341
|
+
version once the author starts editing again: the row moves on to the next draft while the board
|
|
342
|
+
keeps drawing the last release. Naming the draft would send someone to look at a source that is
|
|
343
|
+
not the one failing.
|
|
344
|
+
*/
|
|
345
|
+
it('the refusal names the released edition, not the draft being edited', async () => {
|
|
346
|
+
const repo = dataSource!.getRepository(Figure)
|
|
347
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
348
|
+
|
|
349
|
+
const figure = await repo.save({
|
|
350
|
+
domain: testDomain,
|
|
351
|
+
type: 'BROKEN_HISTORY',
|
|
352
|
+
name: 'in authoring again',
|
|
353
|
+
state: 'draft',
|
|
354
|
+
source: boxNamed('BROKEN_HISTORY', 'healthy-draft'),
|
|
355
|
+
version: 9
|
|
356
|
+
})
|
|
357
|
+
await historyRepo.save({
|
|
358
|
+
domain: testDomain,
|
|
359
|
+
originalId: figure.id,
|
|
360
|
+
type: 'BROKEN_HISTORY',
|
|
361
|
+
name: 'v2',
|
|
362
|
+
state: 'released',
|
|
363
|
+
version: 2,
|
|
364
|
+
source: '{ this is not json'
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
368
|
+
try {
|
|
369
|
+
await expect(new FigureQuery().figureBlueprint('BROKEN_HISTORY', makeContext())).rejects.toMatchObject({
|
|
370
|
+
code: 'figure-release-does-not-compile',
|
|
371
|
+
params: { version: 2 }
|
|
372
|
+
})
|
|
373
|
+
} finally {
|
|
374
|
+
spy.mockRestore()
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
A refusal only becomes a sentence in the caller's language by passing through `SpeakRefusals`,
|
|
380
|
+
which the class declares and which a direct call like the ones above never runs. Without that
|
|
381
|
+
declaration this refusal reaches the board as Apollo's INTERNAL_SERVER_ERROR carrying the English
|
|
382
|
+
fallback, and `extensions.refusal` — the thing that tells a client the server refused on purpose —
|
|
383
|
+
is absent. The tests above stay green either way, so the declaration is read from the source.
|
|
384
|
+
*/
|
|
385
|
+
it('declares SpeakRefusals on the class, or the refusal arrives as a server error', () => {
|
|
386
|
+
const { readFileSync } = require('fs')
|
|
387
|
+
const { join } = require('path')
|
|
388
|
+
const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
|
|
389
|
+
|
|
390
|
+
const at = source.indexOf('export class FigureQuery')
|
|
391
|
+
expect(at).toBeGreaterThan(-1)
|
|
392
|
+
expect(source.slice(0, at)).toContain('@UseMiddleware(SpeakRefusals)')
|
|
393
|
+
})
|
|
394
|
+
|
|
179
395
|
describe('a child domain shadows its parent only with a released version', () => {
|
|
180
396
|
const boxOf = (type: string, part: string) =>
|
|
181
397
|
JSON.stringify({ ...JSON.parse(sampleSource), type, parts: [{ ...JSON.parse(sampleSource).parts[0], name: part }] })
|
|
182
398
|
|
|
399
|
+
let childSeq = 0
|
|
400
|
+
|
|
183
401
|
async function childOf(parent: any) {
|
|
184
402
|
const repo = dataSource!.getRepository(shell.Domain)
|
|
185
|
-
|
|
403
|
+
/* A chain needs a distinct subdomain per level; the column is unique. */
|
|
404
|
+
const name = `child-domain-${++childSeq}`
|
|
405
|
+
const saved = await repo.save({ name, subdomain: name, parent })
|
|
186
406
|
return repo.findOne({ where: { id: saved.id } })
|
|
187
407
|
}
|
|
188
408
|
|
|
@@ -213,6 +433,54 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
213
433
|
|
|
214
434
|
expect(result.groups[0].members[0].name).toBe('tenant')
|
|
215
435
|
})
|
|
436
|
+
|
|
437
|
+
/*
|
|
438
|
+
The two cases above give the child a row whose own state decides the answer. This is the case
|
|
439
|
+
between them: the child released once and is editing again, so its row says draft while a
|
|
440
|
+
release of its own exists. The fallback must not read "the child row is a draft" as "the child
|
|
441
|
+
has nothing" — a tenant that starts a second edit would then see the standard figure come back
|
|
442
|
+
in place of the one it published.
|
|
443
|
+
*/
|
|
444
|
+
it('uses the child own release when the child row is a draft again', async () => {
|
|
445
|
+
const child = await childOf(testDomain)
|
|
446
|
+
|
|
447
|
+
const repo = dataSource!.getRepository(Figure)
|
|
448
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
449
|
+
|
|
450
|
+
await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
|
|
451
|
+
const tenant = await repo.save({ domain: child, type: 'CONVEYOR', name: '테넌트', state: 'draft', source: boxOf('CONVEYOR', 'tenant-wip'), version: 1 })
|
|
452
|
+
await historyRepo.save({
|
|
453
|
+
domain: child,
|
|
454
|
+
originalId: tenant.id,
|
|
455
|
+
type: 'CONVEYOR',
|
|
456
|
+
name: '테넌트 v1',
|
|
457
|
+
state: 'released',
|
|
458
|
+
version: 1,
|
|
459
|
+
source: boxOf('CONVEYOR', 'tenant-v1')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
const result = await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(child))
|
|
463
|
+
|
|
464
|
+
expect(result).not.toBeNull()
|
|
465
|
+
expect(result.groups[0].members[0].name).toBe('tenant-v1')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
/*
|
|
469
|
+
The lookup consults the caller's domain and its parent, and stops there. A grandchild therefore
|
|
470
|
+
does not inherit, which is the current rule rather than an oversight — pinning it here means
|
|
471
|
+
the day someone needs deeper inheritance they change this test on purpose and take the ruling
|
|
472
|
+
with them, instead of finding the depth by accident in a scene that renders nothing.
|
|
473
|
+
*/
|
|
474
|
+
it('does not climb past the immediate parent', async () => {
|
|
475
|
+
const mid = await childOf(testDomain)
|
|
476
|
+
const leaf = await childOf(mid)
|
|
477
|
+
expect(leaf.parentId).toBe(mid.id)
|
|
478
|
+
|
|
479
|
+
const repo = dataSource!.getRepository(Figure)
|
|
480
|
+
await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
|
|
481
|
+
|
|
482
|
+
expect(await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(leaf))).toBeNull()
|
|
483
|
+
})
|
|
216
484
|
})
|
|
217
485
|
|
|
218
486
|
/*
|
|
@@ -62,7 +62,7 @@ describeIf('AI 로 만든 figure 가 발행까지 가나', () => {
|
|
|
62
62
|
test('움직이는 것을 만든다 — 문', async () => {
|
|
63
63
|
const { made, gate } = await walk(
|
|
64
64
|
'자동문',
|
|
65
|
-
'a
|
|
65
|
+
'a two-panel automatic door in a frame. add an animation clip that opens both panels together by sliding them apart.',
|
|
66
66
|
'AUTO_DOOR_E2E'
|
|
67
67
|
)
|
|
68
68
|
expect(gate.blocked).toBe(false)
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
|
-
import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
|
|
2
|
+
import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
|
|
3
3
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
4
4
|
import { compile } from '@hatiolab/figure-model'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
Domain,
|
|
8
|
+
getQueryBuilderFromListParams,
|
|
9
|
+
getRepository,
|
|
10
|
+
ListParam,
|
|
11
|
+
Refusal,
|
|
12
|
+
SpeakRefusals
|
|
13
|
+
} from '@things-factory/shell'
|
|
7
14
|
|
|
8
15
|
import { Figure } from './figure.js'
|
|
9
16
|
import { FigureHistory } from './figure-history.js'
|
|
@@ -11,6 +18,7 @@ import { inspectSource } from './figure-gate.js'
|
|
|
11
18
|
import { FigureInspection, FigureList } from './figure-type.js'
|
|
12
19
|
|
|
13
20
|
@Resolver(Figure)
|
|
21
|
+
@UseMiddleware(SpeakRefusals)
|
|
14
22
|
export class FigureQuery {
|
|
15
23
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
16
24
|
@Query(returns => Figure, { nullable: true, description: 'Finds a single figure by its ID.' })
|
|
@@ -194,14 +202,28 @@ export class FigureQuery {
|
|
|
194
202
|
return null
|
|
195
203
|
}
|
|
196
204
|
|
|
197
|
-
const { figure, source: sourceToCompile } = found
|
|
205
|
+
const { figure, source: sourceToCompile, version } = found
|
|
198
206
|
|
|
199
207
|
try {
|
|
200
208
|
const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
|
|
201
209
|
return compile(parsed)
|
|
202
210
|
} catch (err) {
|
|
203
211
|
console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
|
|
204
|
-
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
Null says one thing here: nobody published this type (ADR-0063 결정 2 보탬). A released figure
|
|
215
|
+
whose stored source no longer compiles is a different fact, and answering null for it makes the
|
|
216
|
+
board draw a stand-in with the reason nowhere on screen — the author published, the catalogue
|
|
217
|
+
says released, and the only trace is the line above. That is the silence ADR-0053 refuses.
|
|
218
|
+
|
|
219
|
+
The board asks per type inside a Promise.all and collects a rejection into `failed`, so this
|
|
220
|
+
refusal costs that one figure its drawing and the scene still opens.
|
|
221
|
+
*/
|
|
222
|
+
throw new Refusal(
|
|
223
|
+
'figure-release-does-not-compile',
|
|
224
|
+
{ type, figureId: figure.id, version },
|
|
225
|
+
'the released source of {type} does not compile. open it in the modeller and release it again.'
|
|
226
|
+
)
|
|
205
227
|
}
|
|
206
228
|
}
|
|
207
229
|
}
|
|
@@ -212,12 +234,15 @@ export class FigureQuery {
|
|
|
212
234
|
* A released row gives its own source; a draft row gives the source of its latest released history.
|
|
213
235
|
* A figure that was never released gives null, because drafts never reach consumers.
|
|
214
236
|
*/
|
|
215
|
-
async function releasedSourceOf(
|
|
237
|
+
async function releasedSourceOf(
|
|
238
|
+
domainId: string,
|
|
239
|
+
type: string
|
|
240
|
+
): Promise<{ figure: Figure; source: string; version: number } | null> {
|
|
216
241
|
const figure = await getRepository(Figure).findOne({ where: { domain: { id: domainId }, type } })
|
|
217
242
|
if (!figure) return null
|
|
218
243
|
|
|
219
244
|
if (figure.state === 'released' && figure.source) {
|
|
220
|
-
return { figure, source: figure.source }
|
|
245
|
+
return { figure, source: figure.source, version: figure.version }
|
|
221
246
|
}
|
|
222
247
|
|
|
223
248
|
const latestReleased = await getRepository(FigureHistory).findOne({
|
|
@@ -225,5 +250,9 @@ async function releasedSourceOf(domainId: string, type: string): Promise<{ figur
|
|
|
225
250
|
order: { version: 'DESC' }
|
|
226
251
|
})
|
|
227
252
|
|
|
228
|
-
|
|
253
|
+
/* The version travels with the source: a refusal names the edition that does not compile, and the
|
|
254
|
+
row's own version is the draft being edited, not the one the board was handed. */
|
|
255
|
+
return latestReleased?.source
|
|
256
|
+
? { figure, source: latestReleased.source, version: latestReleased.version }
|
|
257
|
+
: null
|
|
229
258
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* figure 도구가 지나는 문 — ADR-0072 결정 2.
|
|
2
|
+
* figure 도구가 지나는 문 — ADR-0072 결정 2, ADR-0071 결정 2 보탬(2026-09-18).
|
|
3
3
|
*
|
|
4
|
-
* 선언만 해 두고 아무도 읽지 않으면 그 선언은 다음 사람이 지우거나 바꿔도 아무 일이 없다.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 선언만 해 두고 아무도 읽지 않으면 그 선언은 다음 사람이 지우거나 바꿔도 아무 일이 없다. 이제는
|
|
5
|
+
* 읽는 쪽이 생겼다 — 관문이 이 목록으로 도구를 내보낼지 정한다. 그래서 여기서 묻는 것이 둘이다:
|
|
6
|
+
* 도구가 문을 적었는가, 그리고 **그 문이 무엇인가를 지키는가.**
|
|
7
7
|
*/
|
|
8
8
|
import { readdirSync, readFileSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
@@ -27,31 +27,23 @@ describe('figure tools — declared doors', () => {
|
|
|
27
27
|
expect(undeclared).toEqual([])
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
it('★ a door a tool reaches comes with the privilege that guards it', () => {
|
|
31
|
-
const unguarded = getAllToolSpecs()
|
|
32
|
-
.filter(spec => ((spec as any).doors ?? []).length > 0 && !(spec as any).privilege)
|
|
33
|
-
.map(spec => spec.name)
|
|
34
|
-
|
|
35
|
-
expect(unguarded).toEqual([])
|
|
36
|
-
})
|
|
37
|
-
|
|
38
30
|
it('the registry itself finds nothing to report about these specs', () => {
|
|
39
31
|
expect(getToolRegistryWarnings().map(w => `${w.code}:${w.params.tool ?? ''}`)).toEqual([])
|
|
40
32
|
})
|
|
41
33
|
})
|
|
42
34
|
|
|
43
35
|
/*
|
|
44
|
-
*
|
|
36
|
+
* 도구가 가리키는 문이 무엇인가를 지키는가 — 관문이 딛고 선 사실.
|
|
45
37
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* 도구 명세만 옛 값을 들고 남고, 그때 게이트는 문이 안 묻는 것을 묻거나 문이 묻는 것을 안 묻는다.
|
|
38
|
+
* 2026-09-18 까지 도구 명세가 권한을 베껴 적었고 이 자리가 그 사본을 문과 견줬다. 사본은 없어졌다
|
|
39
|
+
* (ADR-0071 결정 2 보탬) — 관문이 AUTH 의 필드 표에서 문의 선언을 직접 읽는다. 그래서 견줄 두 값도
|
|
40
|
+
* 없다.
|
|
50
41
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
42
|
+
* 대신 관문이 **딛고 선 사실**이 남았다: 이 네 도구가 가리키는 문에 지시자가 붙어 있다는 것. 누가
|
|
43
|
+
* `Query.figures` 에서 `@privilege` 를 떼면 그 문은 테넌트 안에서 열리고, 도구도 같이 열린다 —
|
|
44
|
+
* 그 자리가 이제 아무 데서도 안 드러난다. 여기서 문다.
|
|
53
45
|
*/
|
|
54
|
-
describe('figure tools — the
|
|
46
|
+
describe('figure tools — the doors they reach are guarded', () => {
|
|
55
47
|
beforeEach(() => {
|
|
56
48
|
clearToolRegistry()
|
|
57
49
|
registerFigureToolCategories()
|
|
@@ -112,26 +104,18 @@ describe('figure tools — the copied privilege matches the door', () => {
|
|
|
112
104
|
return found
|
|
113
105
|
}
|
|
114
106
|
|
|
115
|
-
it('★ 도구가
|
|
107
|
+
it('★ 도구가 가리키는 문마다 그 문을 지키는 지시자가 있다', () => {
|
|
116
108
|
const doors = doorPrivileges()
|
|
117
109
|
|
|
118
|
-
const
|
|
119
|
-
(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
declared.privilege !== copied?.privilege ||
|
|
126
|
-
declared.owner !== (copied?.owner ?? false)
|
|
127
|
-
) {
|
|
128
|
-
return `${spec.name} → ${door}: 문은 ${JSON.stringify(declared)} 인데 도구는 ${JSON.stringify(copied)}`
|
|
129
|
-
}
|
|
130
|
-
return undefined
|
|
131
|
-
})
|
|
132
|
-
).filter(Boolean)
|
|
110
|
+
const unguarded = getAllToolSpecs()
|
|
111
|
+
.flatMap(spec =>
|
|
112
|
+
((spec as any).doors ?? []).map((door: string) =>
|
|
113
|
+
doors.has(door) ? undefined : `${spec.name} → ${door}: 그런 문이 없거나 그 문이 권한을 선언하지 않는다`
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
.filter(Boolean)
|
|
133
117
|
|
|
134
|
-
expect(
|
|
118
|
+
expect(unguarded).toEqual([])
|
|
135
119
|
})
|
|
136
120
|
|
|
137
121
|
it('견줄 문을 실제로 찾아 읽었다 — 0 건이면 위 시험은 언제나 초록이다', () => {
|
|
@@ -129,6 +129,72 @@ test('실제 registryAssistantChat 경계가 열린 모델 문맥을 Figure 도
|
|
|
129
129
|
expect(JSON.stringify(seen[1])).not.toContain('FIGURE_TARGET_REQUIRED')
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
+
/*
|
|
133
|
+
등록된 범주에 도구 넷이 있다는 것과, 넷이 **모델에게 건네졌다**는 것은 다른 사실이다. 그 사이에
|
|
134
|
+
관문이 하나 있다 — `resolveToolScope` 가 도구마다 `doors` 를 부르는 사람의 권한에 물어 통과한 것만
|
|
135
|
+
싣는다(ADR-0071 결정 2). 그래서 등록은 넷인데 대화에 실리는 것은 셋일 수 있고, 그때 화면은 아무
|
|
136
|
+
오류도 내지 않는다 — 모델이 그 도구를 안 부르니 「후보를 만들었습니다」로 끝난다.
|
|
137
|
+
|
|
138
|
+
위의 배선 시험은 도구 하나(`getFigureSource`)가 오간 것으로 그 줄을 확인한다. 나머지 셋이 실렸는지는
|
|
139
|
+
보지 않는다. 그래서 건네진 목록 자체를 여기서 읽는다.
|
|
140
|
+
*/
|
|
141
|
+
describe('모델에게 건네지는 목록', () => {
|
|
142
|
+
/** `chatWithTools(messages, toolDefs, options)` 의 둘째 인자를 그대로 잡는다. */
|
|
143
|
+
async function offeredWith(access: (door: string) => Promise<boolean>) {
|
|
144
|
+
const offered: string[][] = []
|
|
145
|
+
|
|
146
|
+
await registryAssistantChat({
|
|
147
|
+
client: {
|
|
148
|
+
chatWithTools: async (_messages: any[], toolDefs: any[]) => {
|
|
149
|
+
offered.push((toolDefs ?? []).map(one => one.name ?? one.function?.name))
|
|
150
|
+
return { text: '네.', toolCalls: [], stopReason: 'end_turn' }
|
|
151
|
+
}
|
|
152
|
+
} as any,
|
|
153
|
+
messages: [{ role: 'user', content: '무엇을 할 수 있나' }],
|
|
154
|
+
systemPrompt: 'Author figures.',
|
|
155
|
+
toolCategories: ['figure-authoring'],
|
|
156
|
+
state: { domain: MINE },
|
|
157
|
+
hostContext: { figureId: 'fig-1', draftSource: SOURCE },
|
|
158
|
+
toolAccess: access
|
|
159
|
+
} as any)
|
|
160
|
+
|
|
161
|
+
expect(offered).toHaveLength(1)
|
|
162
|
+
return offered[0]!.sort()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
it('★ 도구 넷이 다 건네진다 — 문을 다 가진 사람에게', async () => {
|
|
166
|
+
expect(await offeredWith(async () => true)).toEqual([
|
|
167
|
+
'getFigureSource',
|
|
168
|
+
'inspectFigure',
|
|
169
|
+
'listFigures',
|
|
170
|
+
'proposeFigureCandidate'
|
|
171
|
+
])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
/*
|
|
175
|
+
넷이 문 넷을 하나씩 나눠 가지므로, 문 하나를 닫으면 도구 하나만 빠져야 한다. 이 갈래가 없으면
|
|
176
|
+
위 시험은 「관문이 아무것도 안 걸러도」 초록이다.
|
|
177
|
+
|
|
178
|
+
후보를 내는 도구를 닫힌 쪽으로 골랐다. 읽기 권한만 가진 사람이 저작 대화를 열면 목록과 정본과
|
|
179
|
+
검사는 되고 후보 생성은 안 되는 것이 그 사람에게 맞는 모양이다.
|
|
180
|
+
*/
|
|
181
|
+
it('★ 문이 닫힌 도구 하나만 빠진다 — 나머지 셋은 그대로다', async () => {
|
|
182
|
+
const offered = await offeredWith(async door => door !== 'Mutation.proposeFigure')
|
|
183
|
+
|
|
184
|
+
expect(offered).toEqual(['getFigureSource', 'inspectFigure', 'listFigures'])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
권한을 물어볼 길이 없으면 문에 닿는 도구는 전부 닫힌다(`resolveToolScope`: `if (!access) return false`).
|
|
189
|
+
figure 의 넷은 전부 문에 닿으므로 하나도 남지 않는다 — 부분만 열리는 일이 없다는 뜻이다.
|
|
190
|
+
*/
|
|
191
|
+
it('물어볼 길이 없으면 하나도 건네지지 않는다', async () => {
|
|
192
|
+
const offered = await offeredWith(undefined as any)
|
|
193
|
+
|
|
194
|
+
expect(offered).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
132
198
|
describe('범주가 등록된다', () => {
|
|
133
199
|
it('figure-authoring 이 도구 넷과 함께 올라온다', () => {
|
|
134
200
|
const category = getToolCategories().find((one: any) => one.name === 'figure-authoring')!
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* `ai-assistant:mutation` 이 지킨다 — 서로 다른 질문이라 서로 다른 권한이 지킨다. figure 가 board
|
|
19
19
|
* 의 권한을 빌리지 않는다.
|
|
20
20
|
*
|
|
21
|
+
* ── 권한은 적지 않는다. 문에 적힌 것을 관문이 읽는다 ────────────────────────
|
|
22
|
+
* 2026-09-18 까지 도구마다 `privilege: { category: 'figure', ... }` 를 적어 두었다. 문의 지시자와
|
|
23
|
+
* 같은 값이라 그때는 맞았고, 문이 선언을 바꾸는 날 어긋난다 — 어긋나면 느슨한 쪽이 이긴다. 이제
|
|
24
|
+
* 관문이 `doors` 에 적힌 문의 선언을 AUTH 의 표에서 읽어 묻는다(ADR-0071 결정 2 보탬). 여기 남길
|
|
25
|
+
* 것은 **닿는 문**뿐이다.
|
|
26
|
+
*
|
|
21
27
|
* ── 규율을 도구와 함께 보낸다 ───────────────────────────────────────────────
|
|
22
28
|
* 도구 목록만으로는 「언제 반드시 불러야 하는지」를 모른다. twin 이 그 공백에서 사고를 냈다 —
|
|
23
29
|
* 조치 요청에 도구를 부르지 않고 「제안해 두었습니다」라고만 답해, 사용자에게 **없는 버튼을 누르라고
|
|
@@ -249,7 +255,6 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
249
255
|
name: 'listFigures',
|
|
250
256
|
/* 이 도메인의 도형 목록 — 저장소를 직접 읽지만 같은 일을 하는 문은 이것이다. */
|
|
251
257
|
doors: ['Query.figures'],
|
|
252
|
-
privilege: { category: 'figure', privilege: 'query', owner: true },
|
|
253
258
|
description:
|
|
254
259
|
'Lists the figures in this tenant with their type names, states and part counts. ' +
|
|
255
260
|
'Call it before naming a type — type names are stored identifiers and must not be invented.',
|
|
@@ -280,7 +285,6 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
280
285
|
/* 저장된 도형의 원본. **저장 전 초안**을 물으면 저장소에 갈 것이 없어 화면이 보낸 것을 그대로 돌려준다 —
|
|
281
286
|
* 그 갈래에는 닿는 문이 없지만 `doors: []` 로 적지 않는다. 그러면 저장된 도형을 읽는 권한 검사가 선언에서 사라진다. */
|
|
282
287
|
doors: ['Query.figure'],
|
|
283
|
-
privilege: { category: 'figure', privilege: 'query', owner: true },
|
|
284
288
|
description:
|
|
285
289
|
'Returns the canonical FigureSource of one figure, with its measured cost ' +
|
|
286
290
|
'(triangles, part groups, and how many of it hold 60fps on one screen). Call it before proposing a revision — ' +
|
|
@@ -318,7 +322,6 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
318
322
|
name: 'inspectFigure',
|
|
319
323
|
/* 검사 결과 — 같은 일을 하는 문. */
|
|
320
324
|
doors: ['Query.inspectFigure'],
|
|
321
|
-
privilege: { category: 'figure', privilege: 'query', owner: true },
|
|
322
325
|
description:
|
|
323
326
|
'Says whether a figure may be released and, when it may not, what is wrong — each finding ' +
|
|
324
327
|
'carries why it is a problem and what to change. Call it when asked about releasing, or ' +
|
|
@@ -340,7 +343,6 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
340
343
|
name: 'proposeFigureCandidate',
|
|
341
344
|
/* 후보 생성 — 저장은 하지 않지만 같은 문의 권한을 쓴다. */
|
|
342
345
|
doors: ['Mutation.proposeFigure'],
|
|
343
|
-
privilege: { category: 'figure', privilege: 'mutation', owner: true },
|
|
344
346
|
description:
|
|
345
347
|
'Asks for a figure candidate from a description, optionally revising an existing one. ' +
|
|
346
348
|
'Returns the candidate with its score and cost. NOTHING IS SAVED — the author accepts or ' +
|